From 2b34be95d30317c3149011bbb4bda0c2fbc1b2a8 Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Sat, 9 Nov 2024 16:41:48 -0500 Subject: [PATCH 01/31] fix: improper account dropdown --- .../core/components/page-wrapper/TopNavRight.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/ladderly-io/src/app/core/components/page-wrapper/TopNavRight.tsx b/ladderly-io/src/app/core/components/page-wrapper/TopNavRight.tsx index a67b78ae..73560756 100644 --- a/ladderly-io/src/app/core/components/page-wrapper/TopNavRight.tsx +++ b/ladderly-io/src/app/core/components/page-wrapper/TopNavRight.tsx @@ -13,10 +13,11 @@ import { const TOP_NAV_RIGHT_SECTION_CLASSES = "ml-auto flex items-center space-x-6"; export const TopNavRight = () => { - const currentUser = api.user.getCurrentUser.useQuery(); + const currentUserQuery = api.user.getCurrentUser.useQuery(); + const currentUser = currentUserQuery.data; const { setMenu, openMenuName } = React.useContext(MenuContext); - const handleCommunityClick = (e) => { + const handleCommunityClick = (e: React.MouseEvent<HTMLButtonElement>) => { e.preventDefault(); if (openMenuName === "community") { setMenu(null, ""); @@ -25,16 +26,16 @@ export const TopNavRight = () => { } }; - const handleAccountClick = (e) => { + const handleAccountClick = (e: React.MouseEvent<HTMLButtonElement>) => { e.preventDefault(); if (openMenuName === "account") { setMenu(null, ""); - } else if (currentUser.data) { - setMenu(<AccountMenuItems currentUser={currentUser.data} />, "account"); + } else if (currentUser) { + setMenu(<AccountMenuItems currentUser={currentUser} />, "account"); } }; - const handleLeaderboardClick = (e) => { + const handleLeaderboardClick = (e: React.MouseEvent<HTMLButtonElement>) => { e.preventDefault(); if (openMenuName === "leaderboard") { setMenu(null, ""); From 1500162dcabd8fe3454b8ae27a3fde66b94aa78d Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Sun, 10 Nov 2024 16:18:00 -0500 Subject: [PATCH 02/31] feat: migrate community page --- .../src/app/community/ClientCommunityPage.tsx | 82 +++++++++++++++++++ ladderly-io/src/app/community/page.tsx | 24 ++++++ ladderly-io/src/server/api/routers/user.ts | 46 +++++++++++ 3 files changed, 152 insertions(+) create mode 100644 ladderly-io/src/app/community/ClientCommunityPage.tsx create mode 100644 ladderly-io/src/app/community/page.tsx diff --git a/ladderly-io/src/app/community/ClientCommunityPage.tsx b/ladderly-io/src/app/community/ClientCommunityPage.tsx new file mode 100644 index 00000000..ef7e2723 --- /dev/null +++ b/ladderly-io/src/app/community/ClientCommunityPage.tsx @@ -0,0 +1,82 @@ +// src/app/community/ClientCommunityPage.tsx + +"use client"; + +import Link from "next/link"; +import { useSearchParams, useRouter } from "next/navigation"; +import React from "react"; +import { api } from "~/trpc/react"; + +const ITEMS_PER_PAGE = 100; + +interface User { + id: number; + uuid: string; + createdAt: Date; + nameFirst: string | null; + nameLast: string | null; + hasPublicProfileEnabled: boolean; + hasShoutOutsEnabled: boolean; + hasOpenToWork: boolean; + profileBlurb: string | null; + profileContactEmail: string | null; + profileGitHubUri: string | null; + profileHomepageUri: string | null; + profileLinkedInUri: string | null; + totalContributions: number; +} + +export default function ClientCommunityPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const page = Number(searchParams?.get("page") ?? "0"); + + const { data, isLoading } = api.user.getPaginatedUsers.useQuery({ + skip: ITEMS_PER_PAGE * page, + take: ITEMS_PER_PAGE, + }); + + const goToPreviousPage = () => { + const params = new URLSearchParams(searchParams?.toString() ?? ""); + params.set("page", String(page - 1)); + router.push(`?${params.toString()}`); + }; + + const goToNextPage = () => { + const params = new URLSearchParams(searchParams?.toString() ?? ""); + params.set("page", String(page + 1)); + router.push(`?${params.toString()}`); + }; + + if (isLoading) { + return <div>Loading...</div>; + } + + const { users, hasMore } = data ?? { users: [], hasMore: false }; + + return ( + <div> + <ul className="my-4"> + {users.map((user: User) => ( + <li key={user.id}> + <Link href={`/community/${user.id}`}> + {user.nameFirst || `User ${user.id}`} + </Link> + {user.hasOpenToWork && ( + <span className="mx-3 inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800"> + Open to Work + </span> + )} + </li> + ))} + </ul> + + <button disabled={page === 0} onClick={goToPreviousPage}> + Previous + </button> + <button disabled={!hasMore} onClick={goToNextPage} className="pl-4"> + Next + </button> + </div> + ); +} diff --git a/ladderly-io/src/app/community/page.tsx b/ladderly-io/src/app/community/page.tsx new file mode 100644 index 00000000..ea9c5174 --- /dev/null +++ b/ladderly-io/src/app/community/page.tsx @@ -0,0 +1,24 @@ +// src/app/community/page.tsx + +import { Suspense } from "react"; +import { LargeCard } from "~/app/core/components/LargeCard"; +import { LadderlyPageWrapper } from "~/app/core/components/page-wrapper/LadderlyPageWrapper"; +import ClientCommunityPage from "./ClientCommunityPage"; + +export const metadata = { + title: "Community", +}; + +export default function CommunityPage() { + return ( + <LadderlyPageWrapper> + <LargeCard> + <h1 className="text-2xl font-bold text-gray-800">Member Profiles</h1> + <h3>Sorted by Signup Date</h3> + <Suspense fallback="Loading..."> + <ClientCommunityPage /> + </Suspense> + </LargeCard> + </LadderlyPageWrapper> + ); +} diff --git a/ladderly-io/src/server/api/routers/user.ts b/ladderly-io/src/server/api/routers/user.ts index 10f58b5c..e0876eb6 100644 --- a/ladderly-io/src/server/api/routers/user.ts +++ b/ladderly-io/src/server/api/routers/user.ts @@ -4,6 +4,7 @@ import { publicProcedure, } from "~/server/api/trpc"; import { PaymentTierEnum } from "@prisma/client"; +import { z } from "zod"; const tiersOrder = { FREE: 0, @@ -72,4 +73,49 @@ export const userRouter = createTRPCRouter({ return { tier: minTier }; }), + + // TODO: should this be protected? + getPaginatedUsers: publicProcedure + .input( + z.object({ + skip: z.number(), + take: z.number(), + }) + ) + .query(async ({ ctx, input }) => { + const { skip, take } = input; + + const users = await ctx.db.user.findMany({ + where: { + hasPublicProfileEnabled: true, + }, + orderBy: { createdAt: 'desc' }, + skip, + take: take + 1, + select: { + id: true, + uuid: true, + createdAt: true, + nameFirst: true, + nameLast: true, + hasPublicProfileEnabled: true, + hasShoutOutsEnabled: true, + hasOpenToWork: true, + profileBlurb: true, + profileContactEmail: true, + profileGitHubUri: true, + profileHomepageUri: true, + profileLinkedInUri: true, + totalContributions: true, + }, + }); + + const hasMore = users.length > take; + const paginatedUsers = hasMore ? users.slice(0, -1) : users; + + return { + users: paginatedUsers, + hasMore, + }; + }), }); From b0328af5bb3a93e6077855042735977745ec3d7b Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Sun, 10 Nov 2024 16:37:59 -0500 Subject: [PATCH 03/31] feat: migrate public profiles --- .../src/app/community/[userId]/page.tsx | 135 ++++++++++++++++++ ladderly-io/src/app/users/queries/getUser.ts | 50 ------- ladderly-io/src/app/users/queries/getUsers.ts | 58 -------- ladderly-io/src/server/api/routers/user.ts | 61 ++++++++ 4 files changed, 196 insertions(+), 108 deletions(-) create mode 100644 ladderly-io/src/app/community/[userId]/page.tsx delete mode 100644 ladderly-io/src/app/users/queries/getUser.ts delete mode 100644 ladderly-io/src/app/users/queries/getUsers.ts diff --git a/ladderly-io/src/app/community/[userId]/page.tsx b/ladderly-io/src/app/community/[userId]/page.tsx new file mode 100644 index 00000000..514441e3 --- /dev/null +++ b/ladderly-io/src/app/community/[userId]/page.tsx @@ -0,0 +1,135 @@ +import { Suspense } from "react"; +import { LargeCard } from "~/app/core/components/LargeCard"; +import { LadderlyPageWrapper } from "~/app/core/components/page-wrapper/LadderlyPageWrapper"; +import { api } from "~/trpc/server"; +import { TRPCError } from "@trpc/server"; + +// TODO: can we include user name in the title? +export const metadata = { + title: "Public Profile", +}; + +interface ChecklistType { + id: number; + checklist: { + name: string; + version: string; + }; + createdAt: Date; + isComplete: boolean; + updatedAt: Date; +} + +async function UserProfile({ userId }: { userId: number }) { + try { + const user = await api.user.getUser({ id: userId }); + + return ( + <main> + <h1 className="text-2xl"> + {user.nameFirst} {user.nameLast} + </h1> + + <div className="my-4"> + <p>Blurb: {user.profileBlurb}</p> + <p> + Contact Email:{" "} + <a + href={`mailto:${user.profileContactEmail}`} + className="text-blue-600 hover:text-blue-700" + > + {user.profileContactEmail} + </a> + </p> + {user.profileGitHubUri ? ( + <p> + GitHub:{" "} + <a + href={user.profileGitHubUri} + target="_blank" + rel="noopener noreferrer" + className="text-blue-600 hover:text-blue-700" + > + {user.profileGitHubUri} + </a> + </p> + ) : null} + {user.profileHomepageUri ? ( + <p> + Homepage:{" "} + <a + href={user.profileHomepageUri} + target="_blank" + rel="noopener noreferrer" + className="text-blue-600 hover:text-blue-700" + > + {user.profileHomepageUri} + </a> + </p> + ) : null} + {user.profileLinkedInUri ? ( + <p> + LinkedIn:{" "} + <a + href={user.profileLinkedInUri} + target="_blank" + rel="noopener noreferrer" + className="text-blue-600 hover:text-blue-700" + > + {user.profileLinkedInUri} + </a> + </p> + ) : null} + </div> + + {user.userChecklists.length > 0 ? ( + <> + <h2 className="text-lg">Completed Checklists:</h2> + <ul> + {user.userChecklists.map((checklist: ChecklistType) => ( + <li className="my-1" key={checklist.id}> + <p>Checklist: {checklist.checklist.name}</p> + <p>Version: {checklist.checklist.version}</p> + <p> + Completed at:{" "} + {new Date(checklist.updatedAt).toLocaleString()} + </p> + </li> + ))} + </ul> + </> + ) : ( + <p>This user has not completed any checklists.</p> + )} + </main> + ); + } catch (error) { + if (error instanceof TRPCError) { + if (error.code === "UNAUTHORIZED") { + return <div>This profile is private.</div>; + } + if (error.code === "NOT_FOUND") { + return <div>User not found.</div>; + } + } + return <div>An error occurred while loading this profile.</div>; + } +} + +export default async function ShowUserPage({ + params, +}: { + params: { userId: string }; +}) { + const userId = parseInt(params.userId); + + return ( + <LadderlyPageWrapper> + <LargeCard> + <Suspense fallback={<div>Loading...</div>}> + <UserProfile userId={userId} /> + </Suspense> + </LargeCard> + </LadderlyPageWrapper> + ); +} diff --git a/ladderly-io/src/app/users/queries/getUser.ts b/ladderly-io/src/app/users/queries/getUser.ts deleted file mode 100644 index 81b45f86..00000000 --- a/ladderly-io/src/app/users/queries/getUser.ts +++ /dev/null @@ -1,50 +0,0 @@ -// TODO: migrate to trpc - -import { Ctx } from '@blitzjs/next' -import { resolver } from '@blitzjs/rpc' -import { AuthorizationError, NotFoundError } from 'blitz' -import db from 'db' - -export default resolver.pipe(async ({ id }: { id: number }, ctx: Ctx) => { - if (id !== parseInt(id.toString())) throw new NotFoundError() - - const isOwnData = ctx.session.userId === id - const user = await db.user.findUnique({ - where: { id }, - select: { - id: true, - uuid: true, - nameFirst: true, - nameLast: true, - hasPublicProfileEnabled: true, - hasShoutOutsEnabled: true, - hasOpenToWork: true, - profileBlurb: true, - profileContactEmail: true, - profileGitHubUri: true, - profileHomepageUri: true, - profileLinkedInUri: true, - userChecklists: { - where: { isComplete: true }, - take: 3, - orderBy: { createdAt: 'desc' }, - select: { - id: true, - checklist: true, - createdAt: true, - isComplete: true, - updatedAt: true, - }, - }, - }, - }) - - if (!user) throw new NotFoundError('User not found') - if (isOwnData || user.hasPublicProfileEnabled) { - return user - } - - throw new AuthorizationError( - 'You do not have permission to view this user data.' - ) -}) diff --git a/ladderly-io/src/app/users/queries/getUsers.ts b/ladderly-io/src/app/users/queries/getUsers.ts deleted file mode 100644 index c51df881..00000000 --- a/ladderly-io/src/app/users/queries/getUsers.ts +++ /dev/null @@ -1,58 +0,0 @@ -// TODO: migrate to trpc - -import { paginate } from 'blitz' -import { resolver } from '@blitzjs/rpc' -import db, { Prisma } from 'db' - -interface GetUsersInput - extends Pick< - Prisma.UserFindManyArgs, - 'where' | 'orderBy' | 'skip' | 'take' - > {} - -export default resolver.pipe( - async ({ where, orderBy, skip = 0, take = 100 }: GetUsersInput) => { - const { - items: users, - hasMore, - nextPage, - count, - } = await paginate({ - skip, - take, - count: () => db.user.count({ where }), - query: (paginateArgs) => - db.user.findMany({ - ...paginateArgs, - where: { - ...where, - hasPublicProfileEnabled: true, - }, - orderBy, - select: { - id: true, - uuid: true, - createdAt: true, - nameFirst: true, - nameLast: true, - hasPublicProfileEnabled: true, - hasShoutOutsEnabled: true, - hasOpenToWork: true, - profileBlurb: true, - profileContactEmail: true, - profileGitHubUri: true, - profileHomepageUri: true, - profileLinkedInUri: true, - totalContributions: true, - }, - }), - }) - - return { - users, - nextPage, - hasMore, - count, - } - } -) diff --git a/ladderly-io/src/server/api/routers/user.ts b/ladderly-io/src/server/api/routers/user.ts index e0876eb6..47ca8c96 100644 --- a/ladderly-io/src/server/api/routers/user.ts +++ b/ladderly-io/src/server/api/routers/user.ts @@ -5,6 +5,7 @@ import { } from "~/server/api/trpc"; import { PaymentTierEnum } from "@prisma/client"; import { z } from "zod"; +import { TRPCError } from '@trpc/server'; const tiersOrder = { FREE: 0, @@ -118,4 +119,64 @@ export const userRouter = createTRPCRouter({ hasMore, }; }), + + getUser: publicProcedure + .input(z.object({ id: z.number() })) + .query(async ({ ctx, input }) => { + // Validate ID is an integer + if (input.id !== parseInt(input.id.toString())) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'User not found' + }); + } + + const isOwnData = ctx.session?.user?.id === input.id.toString(); + + const user = await ctx.db.user.findUnique({ + where: { id: input.id }, + select: { + id: true, + uuid: true, + nameFirst: true, + nameLast: true, + hasPublicProfileEnabled: true, + hasShoutOutsEnabled: true, + hasOpenToWork: true, + profileBlurb: true, + profileContactEmail: true, + profileGitHubUri: true, + profileHomepageUri: true, + profileLinkedInUri: true, + userChecklists: { + where: { isComplete: true }, + take: 3, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + checklist: true, + createdAt: true, + isComplete: true, + updatedAt: true, + }, + }, + }, + }); + + if (!user) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'User not found' + }); + } + + if (!isOwnData && !user.hasPublicProfileEnabled) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'You do not have permission to view this user data.' + }); + } + + return user; + }), }); From 865060950d98d359cb92913cdb5effe443c4d1fb Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Sun, 10 Nov 2024 16:50:05 -0500 Subject: [PATCH 04/31] chore: install react-final-form --- ladderly-io/package-lock.json | 35 +++++++++++++++++++++++++++++++++++ ladderly-io/package.json | 1 + 2 files changed, 36 insertions(+) diff --git a/ladderly-io/package-lock.json b/ladderly-io/package-lock.json index a1b0ba0a..54380379 100644 --- a/ladderly-io/package-lock.json +++ b/ladderly-io/package-lock.json @@ -22,6 +22,7 @@ "next-auth": "^4.24.7", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-final-form": "^6.5.9", "server-only": "^0.0.1", "superjson": "^2.2.1", "zod": "^3.23.3" @@ -2787,6 +2788,23 @@ "node": ">=8" } }, + "node_modules/final-form": { + "version": "4.20.10", + "resolved": "https://registry.npmjs.org/final-form/-/final-form-4.20.10.tgz", + "integrity": "sha512-TL48Pi1oNHeMOHrKv1bCJUrWZDcD3DIG6AGYVNOnyZPr7Bd/pStN0pL+lfzF5BNoj/FclaoiaLenk4XUIFVYng==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.10.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/final-form" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -4895,6 +4913,23 @@ "react": "^18.3.1" } }, + "node_modules/react-final-form": { + "version": "6.5.9", + "resolved": "https://registry.npmjs.org/react-final-form/-/react-final-form-6.5.9.tgz", + "integrity": "sha512-x3XYvozolECp3nIjly+4QqxdjSSWfcnpGEL5K8OBT6xmGrq5kBqbA6+/tOqoom9NwqIPPbxPNsOViFlbKgowbA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.15.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/final-form" + }, + "peerDependencies": { + "final-form": "^4.20.4", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/ladderly-io/package.json b/ladderly-io/package.json index 9bb848e1..22db408a 100644 --- a/ladderly-io/package.json +++ b/ladderly-io/package.json @@ -28,6 +28,7 @@ "next-auth": "^4.24.7", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-final-form": "^6.5.9", "server-only": "^0.0.1", "superjson": "^2.2.1", "zod": "^3.23.3" From 0f15bd7dcb083532cd6ee9cd397e9c865b8761e9 Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Sun, 10 Nov 2024 16:50:41 -0500 Subject: [PATCH 05/31] feat: SettingsForm --- .../settings/components/CountryDropdown.tsx | 171 ++++++++++++++++++ .../app/settings/components/SettingsForm.tsx | 135 ++++++++++++++ ladderly-io/src/app/settings/schemas.ts | 61 +++++++ 3 files changed, 367 insertions(+) create mode 100644 ladderly-io/src/app/settings/components/CountryDropdown.tsx create mode 100644 ladderly-io/src/app/settings/components/SettingsForm.tsx create mode 100644 ladderly-io/src/app/settings/schemas.ts diff --git a/ladderly-io/src/app/settings/components/CountryDropdown.tsx b/ladderly-io/src/app/settings/components/CountryDropdown.tsx new file mode 100644 index 00000000..8e3f45fa --- /dev/null +++ b/ladderly-io/src/app/settings/components/CountryDropdown.tsx @@ -0,0 +1,171 @@ +import { PropsWithoutRef } from "react"; +import LabeledAutocompleteField from "~/app/core/components/LabeledAutocompleteField"; + +const countries = [ + { value: "", label: "Select a Country" }, + { value: "Afghanistan", label: "Afghanistan" }, + { value: "Albania", label: "Albania" }, + { value: "Algeria", label: "Algeria" }, + { value: "Angola", label: "Angola" }, + { value: "Argentina", label: "Argentina" }, + { value: "Ascension Island", label: "Ascension Island" }, + { value: "Australia", label: "Australia" }, + { value: "Austria", label: "Austria" }, + { value: "Bahrain", label: "Bahrain" }, + { value: "Bangladesh", label: "Bangladesh" }, + { value: "Belarus", label: "Belarus" }, + { value: "Belgium", label: "Belgium" }, + { value: "Belize", label: "Belize" }, + { value: "Benin", label: "Benin" }, + { value: "Bhutan", label: "Bhutan" }, + { value: "Bolivia", label: "Bolivia" }, + { value: "Bosnia and Herzegovina", label: "Bosnia and Herzegovina" }, + { value: "Botswana", label: "Botswana" }, + { value: "Brazil", label: "Brazil" }, + { value: "Bulgaria", label: "Bulgaria" }, + { value: "Burundi", label: "Burundi" }, + { value: "Cameroon", label: "Cameroon" }, + { value: "Canada", label: "Canada" }, + { value: "Cape Verde", label: "Cape Verde" }, + { value: "Central African Republic", label: "Central African Republic" }, + { value: "Chad", label: "Chad" }, + { value: "Chile", label: "Chile" }, + { value: "China", label: "China" }, + { value: "Colombia", label: "Colombia" }, + { value: "Comoros", label: "Comoros" }, + { value: "Congo", label: "Congo" }, + { value: "Costa Rica", label: "Costa Rica" }, + { value: "Croatia", label: "Croatia" }, + { value: "Cyprus", label: "Cyprus" }, + { value: "Czech Republic", label: "Czech Republic" }, + { + value: "Democratic Republic of the Congo", + label: "Democratic Republic of the Congo", + }, + { value: "Denmark", label: "Denmark" }, + { value: "Djibouti", label: "Djibouti" }, + { value: "Ecuador", label: "Ecuador" }, + { value: "Egypt", label: "Egypt" }, + { value: "El Salvador", label: "El Salvador" }, + { value: "Equatorial Guinea", label: "Equatorial Guinea" }, + { value: "Eritrea", label: "Eritrea" }, + { value: "Estonia", label: "Estonia" }, + { value: "Eswatini", label: "Eswatini" }, + { value: "Ethiopia", label: "Ethiopia" }, + { value: "Falkland Islands", label: "Falkland Islands" }, + { value: "Faroe Islands", label: "Faroe Islands" }, + { value: "Finland", label: "Finland" }, + { value: "France", label: "France" }, + { value: "French Guiana", label: "French Guiana" }, + { value: "Gabon", label: "Gabon" }, + { value: "Germany", label: "Germany" }, + { value: "Ghana", label: "Ghana" }, + { value: "Greece", label: "Greece" }, + { value: "Greenland", label: "Greenland" }, + { value: "Guatemala", label: "Guatemala" }, + { value: "Guinea-Bissau", label: "Guinea-Bissau" }, + { value: "Guinea", label: "Guinea" }, + { value: "Guyana", label: "Guyana" }, + { value: "Honduras", label: "Honduras" }, + { value: "Hungary", label: "Hungary" }, + { value: "Iceland", label: "Iceland" }, + { value: "India", label: "India" }, + { value: "Iran", label: "Iran" }, + { value: "Iraq", label: "Iraq" }, + { value: "Ireland", label: "Ireland" }, + { value: "Israel", label: "Israel" }, + { value: "Italy", label: "Italy" }, + { value: "Ivory Coast", label: "Ivory Coast" }, + { value: "Japan", label: "Japan" }, + { value: "Jordan", label: "Jordan" }, + { value: "Kenya", label: "Kenya" }, + { value: "Kosovo", label: "Kosovo" }, + { value: "Kuwait", label: "Kuwait" }, + { value: "Latvia", label: "Latvia" }, + { value: "Lebanon", label: "Lebanon" }, + { value: "Lesotho", label: "Lesotho" }, + { value: "Liberia", label: "Liberia" }, + { value: "Libya", label: "Libya" }, + { value: "Lithuania", label: "Lithuania" }, + { value: "Luxembourg", label: "Luxembourg" }, + { value: "Madagascar", label: "Madagascar" }, + { value: "Malawi", label: "Malawi" }, + { value: "Mali", label: "Mali" }, + { value: "Malta", label: "Malta" }, + { value: "Mauritania", label: "Mauritania" }, + { value: "Mauritius", label: "Mauritius" }, + { value: "Mexico", label: "Mexico" }, + { value: "Moldova", label: "Moldova" }, + { value: "Montenegro", label: "Montenegro" }, + { value: "Morocco", label: "Morocco" }, + { value: "Mozambique", label: "Mozambique" }, + { value: "Namibia", label: "Namibia" }, + { value: "Nepal", label: "Nepal" }, + { value: "Netherlands", label: "Netherlands" }, + { value: "New Zealand", label: "New Zealand" }, + { value: "Nicaragua", label: "Nicaragua" }, + { value: "Niger", label: "Niger" }, + { value: "Nigeria", label: "Nigeria" }, + { value: "North Macedonia", label: "North Macedonia" }, + { value: "Norway", label: "Norway" }, + { value: "Oman", label: "Oman" }, + { value: "Pakistan", label: "Pakistan" }, + { value: "Panama", label: "Panama" }, + { value: "Paraguay", label: "Paraguay" }, + { value: "Peru", label: "Peru" }, + { value: "Poland", label: "Poland" }, + { value: "Portugal", label: "Portugal" }, + { value: "Qatar", label: "Qatar" }, + { value: "Romania", label: "Romania" }, + { value: "Russia", label: "Russia" }, + { value: "Rwanda", label: "Rwanda" }, + { value: "Saint Helena", label: "Saint Helena" }, + { value: "Saudi Arabia", label: "Saudi Arabia" }, + { value: "Senegal", label: "Senegal" }, + { value: "Serbia", label: "Serbia" }, + { value: "Seychelles", label: "Seychelles" }, + { value: "Sierra Leone", label: "Sierra Leone" }, + { value: "Slovakia", label: "Slovakia" }, + { value: "Slovenia", label: "Slovenia" }, + { value: "Somalia", label: "Somalia" }, + { value: "South Africa", label: "South Africa" }, + { value: "South Korea", label: "South Korea" }, + { value: "South Sudan", label: "South Sudan" }, + { value: "Spain", label: "Spain" }, + { value: "Sri Lanka", label: "Sri Lanka" }, + { value: "Sudan", label: "Sudan" }, + { value: "Suriname", label: "Suriname" }, + { value: "Sweden", label: "Sweden" }, + { value: "Switzerland", label: "Switzerland" }, + { value: "Syria", label: "Syria" }, + { value: "Tanzania", label: "Tanzania" }, + { value: "The Gambia", label: "The Gambia" }, + { value: "Togo", label: "Togo" }, + { value: "Tristan da Cunha", label: "Tristan da Cunha" }, + { value: "Tunisia", label: "Tunisia" }, + { value: "Turkey", label: "Turkey" }, + { value: "Uganda", label: "Uganda" }, + { value: "Ukraine", label: "Ukraine" }, + { value: "United Arab Emirates", label: "United Arab Emirates" }, + { value: "United Kingdom", label: "United Kingdom" }, + { value: "United States", label: "United States" }, + { value: "Uruguay", label: "Uruguay" }, + { value: "Venezuela", label: "Venezuela" }, + { value: "Western Sahara", label: "Western Sahara" }, + { value: "Yemen", label: "Yemen" }, + { value: "Zambia", label: "Zambia" }, + { value: "Zimbabwe", label: "Zimbabwe" }, +]; + +export const CountryDropdown = ({ + outerProps, +}: { + outerProps?: PropsWithoutRef<JSX.IntrinsicElements["div"]>; +}) => ( + <LabeledAutocompleteField + name="residenceCountry" + label="Country of Residence" + options={countries} + outerProps={outerProps} + /> +); diff --git a/ladderly-io/src/app/settings/components/SettingsForm.tsx b/ladderly-io/src/app/settings/components/SettingsForm.tsx new file mode 100644 index 00000000..4243f443 --- /dev/null +++ b/ladderly-io/src/app/settings/components/SettingsForm.tsx @@ -0,0 +1,135 @@ +"use client"; + +import React from "react"; +import { UpdateSettingsSchema } from "src/app/settings/schemas"; +import { Form, FormProps } from "~/app/core/components/Form"; +import LabeledCheckboxField from "~/app/core/components/LabeledCheckboxField"; +import LabeledTextField from "~/app/core/components/LabeledTextField"; +import { z } from "zod"; +import { CountryDropdown } from "./CountryDropdown"; +import { USStateDropdown } from "./USStateDropdown"; + +export { FORM_ERROR } from "~/app/core/components/Form"; + +type SettingsFormProps = { + initialValues: z.infer<typeof UpdateSettingsSchema>; + onSubmit: FormProps<typeof UpdateSettingsSchema>["onSubmit"]; +}; + +export function SettingsForm({ initialValues, onSubmit }: SettingsFormProps) { + return ( + <Form + schema={UpdateSettingsSchema} + initialValues={initialValues} + onSubmit={onSubmit} + > + <section> + <h3 className="mt-8 text-xl">Authentication Data</h3> + <LabeledTextField + name="email" + label="Primary Email" + outerProps={{ className: "mt-2" }} + placeholder="Primary Email" + /> + <LabeledTextField + name="emailBackup" + label="Backup Email" + outerProps={{ className: "mt-2" }} + placeholder="Backup Email" + /> + <LabeledTextField + name="emailStripe" + label="Stripe Email" + outerProps={{ className: "mt-2" }} + placeholder="Stripe Email" + /> + </section> + <section> + <h3 className="mt-8 text-xl">Public Profile</h3> + <LabeledTextField + name="nameFirst" + label="First Name" + outerProps={{ className: "mt-2" }} + placeholder="First Name" + /> + <LabeledTextField + name="nameLast" + label="Last Name" + outerProps={{ className: "mt-2" }} + placeholder="Last Name" + /> + + <LabeledCheckboxField name="hasOpenToWork" label="Open To Work" /> + <LabeledCheckboxField + label="Enable Shout Outs" + name="hasShoutOutsEnabled" + /> + + <LabeledTextField + name="profileBlurb" + label="Profile Blurb" + outerProps={{ className: "mt-2" }} + placeholder="Profile Blurb" + /> + <LabeledTextField + name="profileContactEmail" + label="Public Contact Email" + outerProps={{ className: "mt-2" }} + placeholder="Public Contact Email" + /> + <LabeledTextField + name="profileGitHubUri" + label="GitHub URL" + outerProps={{ className: "mt-2" }} + placeholder="GitHub URL" + /> + <LabeledTextField + name="profileHomepageUri" + label="Homepage URL" + outerProps={{ className: "mt-2" }} + placeholder="Homepage URL" + /> + <LabeledTextField + name="profileLinkedInUri" + label="LinkedIn URL" + outerProps={{ className: "mt-2" }} + placeholder="LinkedIn URL" + /> + + <CountryDropdown outerProps={{ className: "mt-2 items-baseline" }} /> + <USStateDropdown outerProps={{ className: "mt-2 items-baseline" }} /> + </section> + <section> + <h3 className="mt-8 text-xl">Features and Interests</h3> + <LabeledCheckboxField + label="Enable Public Profile" + name="hasPublicProfileEnabled" + outerProps={{ className: "mt-2 items-baseline" }} + /> + <LabeledCheckboxField + name="hasSmallGroupInterest" + label="Interested in an Expert-Led Small Group" + /> + <LabeledCheckboxField + name="hasLiveStreamInterest" + label="Interested in Joining a Live Stream" + /> + <LabeledCheckboxField + name="hasOnlineEventInterest" + label="Interested in Online Hackathons and Events" + /> + <LabeledCheckboxField + name="hasInPersonEventInterest" + label="Interested in In-Person Hackathons and Events" + /> + </section> + + <button + type="submit" + className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-white hover:bg-blue-600" + > + Submit + </button> + </Form> + ); +} diff --git a/ladderly-io/src/app/settings/schemas.ts b/ladderly-io/src/app/settings/schemas.ts new file mode 100644 index 00000000..b68dc1ea --- /dev/null +++ b/ladderly-io/src/app/settings/schemas.ts @@ -0,0 +1,61 @@ +import { z } from 'zod' + +const emailRegex = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}$/ +export const isValidOptionalEmail = (value: string) => + value === '' || emailRegex.test(value) + +export const optionalEmailValidator = z + .string() + .refine(isValidOptionalEmail, { + message: 'Invalid email', + }) + .nullable() + .optional() + +export const uriValidator = z + .string() + .refine((value) => value === '' || value.startsWith('http'), { + message: 'Invalid URI', + }) + .nullable() + .optional() + +const optionalGitHubUriValidator = z + .string() + .refine((value) => value === '' || value.includes('github'), { + message: 'Invalid GitHub URL', + }) + .nullable() + .optional() + +const optionalLinkedInUriValidator = z + .string() + .refine((value) => value === '' || value.includes('linkedin'), { + message: 'Invalid LinkedIn URL', + }) + .nullable() + .optional() + +export const UpdateSettingsSchema = z.object({ + email: z.string().email(), + emailBackup: optionalEmailValidator, + emailStripe: optionalEmailValidator, + + hasInPersonEventInterest: z.boolean().optional(), + hasLiveStreamInterest: z.boolean().optional(), + hasOnlineEventInterest: z.boolean().optional(), + hasOpenToWork: z.boolean().optional(), + hasPublicProfileEnabled: z.boolean().optional(), + hasShoutOutsEnabled: z.boolean().optional(), + hasSmallGroupInterest: z.boolean().optional(), + + nameFirst: z.string().nullable().optional(), + nameLast: z.string().nullable().optional(), + profileBlurb: z.string().nullable().optional(), + profileContactEmail: optionalEmailValidator, + profileGitHubUri: optionalGitHubUriValidator, + profileHomepageUri: uriValidator, + profileLinkedInUri: optionalLinkedInUriValidator, + residenceCountry: z.string().optional(), + residenceUSState: z.string().optional(), +}) From fa4525014178e6a9bf8a04daa47229f3b2379bd0 Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Sun, 10 Nov 2024 16:55:30 -0500 Subject: [PATCH 06/31] feat: settings trpc api --- .../settings/components/USStateDropdown.tsx | 76 ++++++++++ ladderly-io/src/server/api/routers/user.ts | 137 ++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 ladderly-io/src/app/settings/components/USStateDropdown.tsx diff --git a/ladderly-io/src/app/settings/components/USStateDropdown.tsx b/ladderly-io/src/app/settings/components/USStateDropdown.tsx new file mode 100644 index 00000000..1efb2ce8 --- /dev/null +++ b/ladderly-io/src/app/settings/components/USStateDropdown.tsx @@ -0,0 +1,76 @@ +import React, { PropsWithoutRef } from "react"; +import LabeledAutocompleteField from "~/app/core/components/LabeledAutocompleteField"; + +const usStates = [ + { value: "", label: "Select a U.S. State" }, + { value: "Not U.S. Resident", label: "Not U.S. Resident" }, + { value: "Alabama", label: "Alabama" }, + { value: "Alaska", label: "Alaska" }, + { value: "American Samoa", label: "American Samoa" }, + { value: "Arizona", label: "Arizona" }, + { value: "Arkansas", label: "Arkansas" }, + { value: "California", label: "California" }, + { value: "Colorado", label: "Colorado" }, + { value: "Connecticut", label: "Connecticut" }, + { value: "Delaware", label: "Delaware" }, + { value: "District of Columbia", label: "District of Columbia" }, + { value: "Florida", label: "Florida" }, + { value: "Georgia", label: "Georgia" }, + { value: "Guam", label: "Guam" }, + { value: "Hawaii", label: "Hawaii" }, + { value: "Idaho", label: "Idaho" }, + { value: "Illinois", label: "Illinois" }, + { value: "Indiana", label: "Indiana" }, + { value: "Iowa", label: "Iowa" }, + { value: "Kansas", label: "Kansas" }, + { value: "Kentucky", label: "Kentucky" }, + { value: "Louisiana", label: "Louisiana" }, + { value: "Maine", label: "Maine" }, + { value: "Maryland", label: "Maryland" }, + { value: "Massachusetts", label: "Massachusetts" }, + { value: "Michigan", label: "Michigan" }, + { value: "Minnesota", label: "Minnesota" }, + { value: "Mississippi", label: "Mississippi" }, + { value: "Missouri", label: "Missouri" }, + { value: "Montana", label: "Montana" }, + { value: "Nebraska", label: "Nebraska" }, + { value: "Nevada", label: "Nevada" }, + { value: "New Hampshire", label: "New Hampshire" }, + { value: "New Jersey", label: "New Jersey" }, + { value: "New Mexico", label: "New Mexico" }, + { value: "New York", label: "New York" }, + { value: "North Carolina", label: "North Carolina" }, + { value: "North Dakota", label: "North Dakota" }, + { value: "Northern Mariana Islands", label: "Northern Mariana Islands" }, + { value: "Ohio", label: "Ohio" }, + { value: "Oklahoma", label: "Oklahoma" }, + { value: "Oregon", label: "Oregon" }, + { value: "Pennsylvania", label: "Pennsylvania" }, + { value: "Puerto Rico", label: "Puerto Rico" }, + { value: "Rhode Island", label: "Rhode Island" }, + { value: "South Carolina", label: "South Carolina" }, + { value: "South Dakota", label: "South Dakota" }, + { value: "Tennessee", label: "Tennessee" }, + { value: "Texas", label: "Texas" }, + { value: "U.S. Virgin Islands", label: "U.S. Virgin Islands" }, + { value: "Utah", label: "Utah" }, + { value: "Vermont", label: "Vermont" }, + { value: "Virginia", label: "Virginia" }, + { value: "Washington", label: "Washington" }, + { value: "West Virginia", label: "West Virginia" }, + { value: "Wisconsin", label: "Wisconsin" }, + { value: "Wyoming", label: "Wyoming" }, +]; + +export const USStateDropdown = ({ + outerProps, +}: { + outerProps?: PropsWithoutRef<JSX.IntrinsicElements["div"]>; +}) => ( + <LabeledAutocompleteField + name="residenceState" + label="State of Residence" + options={usStates} + outerProps={outerProps} + /> +); diff --git a/ladderly-io/src/server/api/routers/user.ts b/ladderly-io/src/server/api/routers/user.ts index 47ca8c96..fc661898 100644 --- a/ladderly-io/src/server/api/routers/user.ts +++ b/ladderly-io/src/server/api/routers/user.ts @@ -13,6 +13,28 @@ const tiersOrder = { PREMIUM: 2, } as const; +const UpdateSettingsSchema = z.object({ + email: z.string(), + emailBackup: z.string().optional(), + emailStripe: z.string().optional(), + nameFirst: z.string().optional(), + nameLast: z.string().optional(), + hasOpenToWork: z.boolean(), + hasShoutOutsEnabled: z.boolean(), + profileBlurb: z.string().nullable(), + profileContactEmail: z.string().nullable(), + profileGitHubUri: z.string().nullable(), + profileHomepageUri: z.string().nullable(), + profileLinkedInUri: z.string().nullable(), + residenceCountry: z.string(), + residenceUSState: z.string(), + hasPublicProfileEnabled: z.boolean(), + hasSmallGroupInterest: z.boolean(), + hasLiveStreamInterest: z.boolean(), + hasOnlineEventInterest: z.boolean(), + hasInPersonEventInterest: z.boolean(), +}); + export const userRouter = createTRPCRouter({ getCurrentUser: publicProcedure.query(async ({ ctx }) => { if (!ctx.session) { @@ -177,6 +199,121 @@ export const userRouter = createTRPCRouter({ }); } + return user; + }), + + getSettings: protectedProcedure.query(async ({ ctx }) => { + const id = parseInt(ctx.session.user.id); + + const result = await ctx.db.$transaction(async (tx) => { + const user = await tx.user.findUnique({ + where: { id }, + include: { + subscriptions: { + where: { type: 'ACCOUNT_PLAN' }, + select: { tier: true, type: true }, + }, + }, + }); + + if (!user) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'User not found' + }); + } + + let subscription = user.subscriptions[0]; + + if (!subscription) { + subscription = await tx.subscription.create({ + data: { + userId: id, + tier: PaymentTierEnum.FREE, + type: 'ACCOUNT_PLAN', + }, + }); + } + + return { + id: user.id, + email: user.email, + emailBackup: user.emailBackup, + emailStripe: user.emailStripe, + nameFirst: user.nameFirst, + nameLast: user.nameLast, + hasOpenToWork: user.hasOpenToWork, + hasShoutOutsEnabled: user.hasShoutOutsEnabled, + profileBlurb: user.profileBlurb, + profileContactEmail: user.profileContactEmail, + profileGitHubUri: user.profileGitHubUri, + profileHomepageUri: user.profileHomepageUri, + profileLinkedInUri: user.profileLinkedInUri, + residenceCountry: user.residenceCountry, + residenceUSState: user.residenceUSState, + hasPublicProfileEnabled: user.hasPublicProfileEnabled, + hasSmallGroupInterest: user.hasSmallGroupInterest, + hasLiveStreamInterest: user.hasLiveStreamInterest, + hasOnlineEventInterest: user.hasOnlineEventInterest, + hasInPersonEventInterest: user.hasInPersonEventInterest, + subscription: { + tier: subscription.tier, + type: subscription.type, + }, + }; + }); + + return result; + }), + + updateSettings: protectedProcedure + .input(UpdateSettingsSchema) + .mutation(async ({ ctx, input }) => { + const userId = parseInt(ctx.session.user.id); + const email = input.email.toLowerCase().trim(); + const emailBackup = input.emailBackup?.toLowerCase().trim() || ''; + const emailStripe = input.emailStripe?.toLowerCase().trim() || ''; + + // Basic email validation + const isValidEmail = (email: string) => + email === '' || (email.includes('@') && email.includes('.')); + + if ( + !isValidEmail(email) || + !isValidEmail(emailBackup) || + !isValidEmail(emailStripe) + ) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Invalid email format' + }); + } + + const user = await ctx.db.user.update({ + where: { id: userId }, + data: { + email, + emailBackup, + emailStripe, + hasInPersonEventInterest: input.hasInPersonEventInterest, + hasOnlineEventInterest: input.hasOnlineEventInterest, + hasLiveStreamInterest: input.hasLiveStreamInterest, + hasOpenToWork: input.hasOpenToWork, + hasPublicProfileEnabled: input.hasPublicProfileEnabled, + hasShoutOutsEnabled: input.hasShoutOutsEnabled, + hasSmallGroupInterest: input.hasSmallGroupInterest, + nameFirst: input.nameFirst?.trim() || '', + nameLast: input.nameLast?.trim() || '', + profileBlurb: input.profileBlurb?.trim() || null, + profileContactEmail: input.profileContactEmail?.toLowerCase().trim() || null, + profileGitHubUri: input.profileGitHubUri?.trim() || null, + profileHomepageUri: input.profileHomepageUri?.trim() || null, + profileLinkedInUri: input.profileLinkedInUri?.trim() || null, + residenceCountry: input.residenceCountry?.trim() || '', + residenceUSState: input.residenceUSState?.trim() || '', + }, + }); + return user; }), }); From 687d21a2dca83fb6baead59c6dc16facdb8feda7 Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Sun, 10 Nov 2024 17:03:08 -0500 Subject: [PATCH 07/31] feat: form doesn't use blitz --- ladderly-io/src/app/core/components/Form.tsx | 47 +++++++++++++------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/ladderly-io/src/app/core/components/Form.tsx b/ladderly-io/src/app/core/components/Form.tsx index 6101f800..a7fa5a06 100644 --- a/ladderly-io/src/app/core/components/Form.tsx +++ b/ladderly-io/src/app/core/components/Form.tsx @@ -1,23 +1,37 @@ -import { ReactNode, PropsWithoutRef } from 'react' +import { ReactNode, PropsWithoutRef } from "react"; import { Form as FinalForm, FormProps as FinalFormProps, -} from 'react-final-form' -import { z } from 'zod' -import { validateZodSchema } from 'blitz' -export { FORM_ERROR } from 'final-form' +} from "react-final-form"; +import { z } from "zod"; +import { FORM_ERROR } from "final-form"; export interface FormProps<S extends z.ZodType<any, any>> - extends Omit<PropsWithoutRef<JSX.IntrinsicElements['form']>, 'onSubmit'> { - /** All your form fields */ - children?: ReactNode - /** Text to display in the submit button */ - submitText?: string - schema?: S - onSubmit: FinalFormProps<z.infer<S>>['onSubmit'] - initialValues?: FinalFormProps<z.infer<S>>['initialValues'] + extends Omit<PropsWithoutRef<JSX.IntrinsicElements["form"]>, "onSubmit"> { + children?: ReactNode; + submitText?: string; + schema?: S; + onSubmit: FinalFormProps<z.infer<S>>["onSubmit"]; + initialValues?: FinalFormProps<z.infer<S>>["initialValues"]; } +export const validateZodSchema = <T extends z.ZodType<any, any>>( + schema: T | undefined +) => { + return (values: z.infer<T>) => { + if (!schema) return {}; + try { + schema.parse(values); + return {}; + } catch (error) { + if (error instanceof z.ZodError) { + return error.formErrors.fieldErrors; + } + return { [FORM_ERROR]: "Form validation failed" }; + } + }; +}; + export function Form<S extends z.ZodType<any, any>>({ children, submitText, @@ -33,11 +47,10 @@ export function Form<S extends z.ZodType<any, any>>({ onSubmit={onSubmit} render={({ handleSubmit, submitting, submitError }) => ( <form onSubmit={handleSubmit} className="form" {...props}> - {/* Form fields supplied as children are rendered here */} {children} {submitError && ( - <div role="alert" style={{ color: 'red' }}> + <div role="alert" style={{ color: "red" }}> {submitError} </div> )} @@ -54,7 +67,7 @@ export function Form<S extends z.ZodType<any, any>>({ </form> )} /> - ) + ); } -export default Form +export { FORM_ERROR }; From bca9d8ac97828984594576a1576b7ebd31e191a0 Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Sun, 10 Nov 2024 17:03:28 -0500 Subject: [PATCH 08/31] feat: broken wip settings page migrate --- .../components/SettingsFormWrapper.tsx | 61 +++++++++++++++++++ ladderly-io/src/app/settings/page.tsx | 45 ++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 ladderly-io/src/app/settings/components/SettingsFormWrapper.tsx create mode 100644 ladderly-io/src/app/settings/page.tsx diff --git a/ladderly-io/src/app/settings/components/SettingsFormWrapper.tsx b/ladderly-io/src/app/settings/components/SettingsFormWrapper.tsx new file mode 100644 index 00000000..ad4e00cb --- /dev/null +++ b/ladderly-io/src/app/settings/components/SettingsFormWrapper.tsx @@ -0,0 +1,61 @@ +// app/settings/SettingsForm.tsx + +"use client"; + +import { useState } from "react"; +import { api } from "~/trpc/react"; +import { SettingsForm } from "./SettingsForm"; + +// Define the settings type based on the schema +type UserSettings = { + id: number; + email: string; + emailBackup: string; + emailStripe: string; + nameFirst: string; + nameLast: string; + hasOpenToWork: boolean; + hasShoutOutsEnabled: boolean; + profileBlurb: string | null; + profileContactEmail: string | null; + profileGitHubUri: string | null; + profileHomepageUri: string | null; + profileLinkedInUri: string | null; + residenceCountry: string; + residenceUSState: string; + hasPublicProfileEnabled: boolean; + hasSmallGroupInterest: boolean; + hasLiveStreamInterest: boolean; + hasOnlineEventInterest: boolean; + hasInPersonEventInterest: boolean; + subscription: { + tier: string; + type: string; + }; +}; + +interface SettingsFormWrapperProps { + initialSettings: UserSettings; +} + +export function SettingsFormWrapper({ + initialSettings, +}: SettingsFormWrapperProps) { + const [settings, setSettings] = useState<UserSettings>(initialSettings); + const { mutate: updateSettings } = api.user.updateSettings.useMutation({ + onSuccess: (updatedSettings) => { + setSettings((prev: UserSettings) => ({ ...prev, ...updatedSettings })); + alert("Updated successfully."); + }, + onError: (error) => { + console.error("Failed to update settings:", error); + alert("Update failed: " + error.message); + }, + }); + + const handleSubmit = async (values: UserSettings) => { + updateSettings(values); + }; + + return <SettingsForm initialValues={settings} onSubmit={handleSubmit} />; +} diff --git a/ladderly-io/src/app/settings/page.tsx b/ladderly-io/src/app/settings/page.tsx new file mode 100644 index 00000000..021e4353 --- /dev/null +++ b/ladderly-io/src/app/settings/page.tsx @@ -0,0 +1,45 @@ +import Link from "next/link"; +import { Suspense } from "react"; +import { api } from "~/trpc/server"; +import { LargeCard } from "~/app/core/components/LargeCard"; +import { LadderlyPageWrapper } from "~/app/core/components/page-wrapper/LadderlyPageWrapper"; +import { SettingsFormWrapper } from "./components/SettingsFormWrapper"; +import { redirect } from "next/navigation"; + +export const metadata = { + title: "Settings", +}; + +export default async function SettingsPage() { + try { + const settings = await api.user.getSettings(); + + return ( + <LadderlyPageWrapper> + <div className="flex items-center justify-center"> + <LargeCard> + <h1 className="text-2xl font-bold text-gray-800"> + Edit User Settings + </h1> + <p className="mt-4"> + Please email john@ladderly.io to update your subscription tier. + </p> + + <Link + className="mt-4 block text-ladderly-violet-700 underline" + href={`/blog/2024-02-16-user-settings` as any} + > + Learn More About User Settings + </Link> + + <Suspense fallback={<div>Loading form...</div>}> + <SettingsFormWrapper initialSettings={settings} /> + </Suspense> + </LargeCard> + </div> + </LadderlyPageWrapper> + ); + } catch (error) { + redirect("/"); + } +} From eae5349da6408573ee5b83528bbbc83e5b939908 Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Sun, 10 Nov 2024 18:41:35 -0500 Subject: [PATCH 09/31] chore: install react-select and npm audit fix --- ladderly-io/package-lock.json | 645 ++++++++++++++++++++++++++++++---- ladderly-io/package.json | 1 + 2 files changed, 575 insertions(+), 71 deletions(-) diff --git a/ladderly-io/package-lock.json b/ladderly-io/package-lock.json index 54380379..d8be6657 100644 --- a/ladderly-io/package-lock.json +++ b/ladderly-io/package-lock.json @@ -23,6 +23,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-final-form": "^6.5.9", + "react-select": "^5.8.3", "server-only": "^0.0.1", "superjson": "^2.2.1", "zod": "^3.23.3" @@ -57,16 +58,18 @@ } }, "node_modules/@auth/core": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.29.0.tgz", - "integrity": "sha512-MdfEjU6WRjUnPG1+XeBWrTIlAsLZU6V0imCIqVDDDPxLI6UZWldXVqAA2EsDazGofV78jqiCLHaN85mJITDqdg==", + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.34.2.tgz", + "integrity": "sha512-KywHKRgLiF3l7PLyL73fjLSIBe1YNcA6sMeew4yMP6cfCWGXZrkkXd32AjRi1hlJ9nvovUBGZHvbn+LijO6ZeQ==", "license": "ISC", + "optional": true, + "peer": true, "dependencies": { "@panva/hkdf": "^1.1.1", "@types/cookie": "0.6.0", "cookie": "0.6.0", "jose": "^5.1.3", - "oauth4webapi": "^2.4.0", + "oauth4webapi": "^2.10.4", "preact": "10.11.3", "preact-render-to-string": "5.2.3" }, @@ -99,6 +102,113 @@ "@prisma/client": ">=2.26.0 || >=3 || >=4 || >=5" } }, + "node_modules/@auth/prisma-adapter/node_modules/@auth/core": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.29.0.tgz", + "integrity": "sha512-MdfEjU6WRjUnPG1+XeBWrTIlAsLZU6V0imCIqVDDDPxLI6UZWldXVqAA2EsDazGofV78jqiCLHaN85mJITDqdg==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.1.1", + "@types/cookie": "0.6.0", + "cookie": "0.6.0", + "jose": "^5.1.3", + "oauth4webapi": "^2.4.0", + "preact": "10.11.3", + "preact-render-to-string": "5.2.3" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@babel/code-frame": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.2.tgz", + "integrity": "sha512-zevQbhbau95nkoxSq3f/DC/SC+EEOUZd3DYqfSkMhY2/wfSeaHV1Ew4vk8e+x8lja31IbyuUa2uQ3JONqKbysw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.26.2", + "@babel/types": "^7.26.0", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", + "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.2.tgz", + "integrity": "sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.25.0", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.0.tgz", @@ -111,6 +221,174 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/template": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", + "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.9.tgz", + "integrity": "sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.25.9", + "@babel/generator": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/template": "^7.25.9", + "@babel/types": "^7.25.9", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/types": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.0.tgz", + "integrity": "sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.12.0.tgz", + "integrity": "sha512-y2WQb+oP8Jqvvclh8Q55gLUyb7UFvgv7eJfsj7td5TToBrIUtPay2kMrZi4xjq9qw2vD0ZR5fSho0yqoFgX7Rw==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.2.0", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.13.1", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.13.1.tgz", + "integrity": "sha512-iqouYkuEblRcXmylXIwwOodiEK5Ifl7JcX7o6V4jI3iW4mLXX3dmt5xwBtIkJiQEXFAI+pC8X0i67yiPkH9Ucw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.0", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.13.3", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.13.3.tgz", + "integrity": "sha512-lIsdU6JNrmYfJ5EbUCf4xW1ovy5wKQ2CkPRM4xogziOxH1nXxBSjpC9YqbFAP7circxMfYp+6x676BqWcEiixg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.12.0", + "@emotion/cache": "^11.13.0", + "@emotion/serialize": "^1.3.1", + "@emotion/use-insertion-effect-with-fallbacks": "^1.1.0", + "@emotion/utils": "^1.4.0", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.2.tgz", + "integrity": "sha512-grVnMvVPK9yUVE6rkKfAJlYZgo0cu3l9iMC77V7DW6E1DUIrU68pSEXRmFZFOFB1QFo57TncmOcvcbMDWsL4yA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.1", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.1.0.tgz", + "integrity": "sha512-+wBOcIV5snwGgI2ya3u99D7/FJquOIniQT1IKyDsBmEgwvpxMNeS65Oib7OnE2d2aY+3BU4OiH+0Wchf8yk3Hw==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.1.tgz", + "integrity": "sha512-BymCXzCG3r72VKJxaYVwOXATqXIZ85cuvg0YOUDxMGNrKc1DJRZk8MgV5wyXRyEayIMd4FuXJIUgTBXvDNW5cA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -195,6 +473,31 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", + "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.12.tgz", + "integrity": "sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", + "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==", + "license": "MIT" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -350,9 +653,9 @@ } }, "node_modules/@next/env": { - "version": "14.2.5", - "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.5.tgz", - "integrity": "sha512-/zZGkrTOsraVfYjGP8uM0p6r0BDT6xWpkjdVbcz66PJVSpwXX3yNiRycxAuDfBKGWBrZBXRuK/YVlkNgxHGwmA==", + "version": "14.2.17", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.17.tgz", + "integrity": "sha512-MCgO7VHxXo8sYR/0z+sk9fGyJJU636JyRmkjc7ZJY8Hurl8df35qG5hoAh5KMs75FLjhlEo9bb2LGe89Y/scDA==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -366,9 +669,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "14.2.5", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.5.tgz", - "integrity": "sha512-/9zVxJ+K9lrzSGli1///ujyRfon/ZneeZ+v4ptpiPoOU+GKZnm8Wj8ELWU1Pm7GHltYRBklmXMTUqM/DqQ99FQ==", + "version": "14.2.17", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.17.tgz", + "integrity": "sha512-WiOf5nElPknrhRMTipXYTJcUz7+8IAjOYw3vXzj3BYRcVY0hRHKWgTgQ5439EvzQyHEko77XK+yN9x9OJ0oOog==", "cpu": [ "arm64" ], @@ -382,9 +685,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "14.2.5", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.5.tgz", - "integrity": "sha512-vXHOPCwfDe9qLDuq7U1OYM2wUY+KQ4Ex6ozwsKxp26BlJ6XXbHleOUldenM67JRyBfVjv371oneEvYd3H2gNSA==", + "version": "14.2.17", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.17.tgz", + "integrity": "sha512-29y425wYnL17cvtxrDQWC3CkXe/oRrdt8ie61S03VrpwpPRI0XsnTvtKO06XCisK4alaMnZlf8riwZIbJTaSHQ==", "cpu": [ "x64" ], @@ -398,9 +701,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.2.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.5.tgz", - "integrity": "sha512-vlhB8wI+lj8q1ExFW8lbWutA4M2ZazQNvMWuEDqZcuJJc78iUnLdPPunBPX8rC4IgT6lIx/adB+Cwrl99MzNaA==", + "version": "14.2.17", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.17.tgz", + "integrity": "sha512-SSHLZls3ZwNEHsc+d0ynKS+7Af0Nr8+KTUBAy9pm6xz9SHkJ/TeuEg6W3cbbcMSh6j4ITvrjv3Oi8n27VR+IPw==", "cpu": [ "arm64" ], @@ -414,9 +717,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.2.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.5.tgz", - "integrity": "sha512-NpDB9NUR2t0hXzJJwQSGu1IAOYybsfeB+LxpGsXrRIb7QOrYmidJz3shzY8cM6+rO4Aojuef0N/PEaX18pi9OA==", + "version": "14.2.17", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.17.tgz", + "integrity": "sha512-VFge37us5LNPatB4F7iYeuGs9Dprqe4ZkW7lOEJM91r+Wf8EIdViWHLpIwfdDXinvCdLl6b4VyLpEBwpkctJHA==", "cpu": [ "arm64" ], @@ -430,9 +733,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.2.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.5.tgz", - "integrity": "sha512-8XFikMSxWleYNryWIjiCX+gU201YS+erTUidKdyOVYi5qUQo/gRxv/3N1oZFCgqpesN6FPeqGM72Zve+nReVXQ==", + "version": "14.2.17", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.17.tgz", + "integrity": "sha512-aaQlpxUVb9RZ41adlTYVQ3xvYEfBPUC8+6rDgmQ/0l7SvK8S1YNJzPmDPX6a4t0jLtIoNk7j+nroS/pB4nx7vQ==", "cpu": [ "x64" ], @@ -446,9 +749,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "14.2.5", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.5.tgz", - "integrity": "sha512-6QLwi7RaYiQDcRDSU/os40r5o06b5ue7Jsk5JgdRBGGp8l37RZEh9JsLSM8QF0YDsgcosSeHjglgqi25+m04IQ==", + "version": "14.2.17", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.17.tgz", + "integrity": "sha512-HSyEiFaEY3ay5iATDqEup5WAfrhMATNJm8dYx3ZxL+e9eKv10XKZCwtZByDoLST7CyBmyDz+OFJL1wigyXeaoA==", "cpu": [ "x64" ], @@ -462,9 +765,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.2.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.5.tgz", - "integrity": "sha512-1GpG2VhbspO+aYoMOQPQiqc/tG3LzmsdBH0LhnDS3JrtDx2QmzXe0B6mSZZiN3Bq7IOMXxv1nlsjzoS1+9mzZw==", + "version": "14.2.17", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.17.tgz", + "integrity": "sha512-h5qM9Btqv87eYH8ArrnLoAHLyi79oPTP2vlGNSg4CDvUiXgi7l0+5KuEGp5pJoMhjuv9ChRdm7mRlUUACeBt4w==", "cpu": [ "arm64" ], @@ -478,9 +781,9 @@ } }, "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.2.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.5.tgz", - "integrity": "sha512-Igh9ZlxwvCDsu6438FXlQTHlRno4gFpJzqPjSIBZooD22tKeI4fE/YMRoHVJHmrQ2P5YL1DoZ0qaOKkbeFWeMg==", + "version": "14.2.17", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.17.tgz", + "integrity": "sha512-BD/G++GKSLexQjdyoEUgyo5nClU7er5rK0sE+HlEqnldJSm96CIr/+YOTT063LVTT/dUOeQsNgp5DXr86/K7/A==", "cpu": [ "ia32" ], @@ -494,9 +797,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.2.5", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.5.tgz", - "integrity": "sha512-tEQ7oinq1/CjSG9uSTerca3v4AZ+dFa+4Yu6ihaG8Ud8ddqLQgFGcnwYls13H5X5CPDPZJdYxyeMui6muOLd4g==", + "version": "14.2.17", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.17.tgz", + "integrity": "sha512-vkQfN1+4V4KqDibkW2q0sJ6CxQuXq5l2ma3z0BRcfIqkAMZiiW67T9yCpwqJKP68QghBtPEFjPAlaqe38O6frw==", "cpu": [ "x64" ], @@ -826,18 +1129,22 @@ "undici-types": "~6.13.0" } }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", - "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.3", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", - "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -854,6 +1161,15 @@ "@types/react": "*" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.11", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.11.tgz", + "integrity": "sha512-RM05tAniPZ5DZPzzNFP+DmrcOdD0efDUxMy3145oljWSl3x9ZV5vhme98gTxFrj2lhXvmGNnUiuDyJgY9IKkNA==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.18.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", @@ -1371,6 +1687,21 @@ "deep-equal": "^2.0.5" } }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1445,7 +1776,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -1573,6 +1903,12 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, "node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", @@ -1597,6 +1933,31 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -1627,7 +1988,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -1695,7 +2055,6 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", - "dev": true, "license": "MIT", "dependencies": { "ms": "2.1.2" @@ -1823,6 +2182,16 @@ "node": ">=6.0.0" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -1849,6 +2218,15 @@ "node": ">=10.13.0" } }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/es-abstract": { "version": "1.23.3", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", @@ -2040,7 +2418,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -2805,6 +3182,12 @@ "url": "https://opencollective.com/final-form" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -3190,6 +3573,15 @@ "node": ">= 0.4" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3204,7 +3596,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -3295,6 +3686,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, "node_modules/is-async-function": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", @@ -3764,6 +4161,18 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -3771,6 +4180,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -3931,6 +4346,12 @@ "node": ">=10" } }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3941,9 +4362,9 @@ } }, "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -3991,7 +4412,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -4031,12 +4451,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "14.2.5", - "resolved": "https://registry.npmjs.org/next/-/next-14.2.5.tgz", - "integrity": "sha512-0f8aRfBVL+mpzfBjYfQuLWh2WyAwtJXCRfkPF4UJ5qd2YwrHczsrSzXU4tRMV0OAxR8ZJZWPFn6uhSC56UTsLA==", + "version": "14.2.17", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.17.tgz", + "integrity": "sha512-hNo/Zy701DDO3nzKkPmsLRlDfNCtb1OJxFUvjGEl04u7SFa3zwC6hqsOUzMajcaEOEV8ey1GjvByvrg0Qr5AiQ==", "license": "MIT", "dependencies": { - "@next/env": "14.2.5", + "@next/env": "14.2.17", "@swc/helpers": "0.5.5", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", @@ -4051,15 +4471,15 @@ "node": ">=18.17.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "14.2.5", - "@next/swc-darwin-x64": "14.2.5", - "@next/swc-linux-arm64-gnu": "14.2.5", - "@next/swc-linux-arm64-musl": "14.2.5", - "@next/swc-linux-x64-gnu": "14.2.5", - "@next/swc-linux-x64-musl": "14.2.5", - "@next/swc-win32-arm64-msvc": "14.2.5", - "@next/swc-win32-ia32-msvc": "14.2.5", - "@next/swc-win32-x64-msvc": "14.2.5" + "@next/swc-darwin-arm64": "14.2.17", + "@next/swc-darwin-x64": "14.2.17", + "@next/swc-linux-arm64-gnu": "14.2.17", + "@next/swc-linux-arm64-musl": "14.2.17", + "@next/swc-linux-x64-gnu": "14.2.17", + "@next/swc-linux-x64-musl": "14.2.17", + "@next/swc-win32-arm64-msvc": "14.2.17", + "@next/swc-win32-ia32-msvc": "14.2.17", + "@next/swc-win32-x64-msvc": "14.2.17" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -4081,14 +4501,14 @@ } }, "node_modules/next-auth": { - "version": "4.24.7", - "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.7.tgz", - "integrity": "sha512-iChjE8ov/1K/z98gdKbn2Jw+2vLgJtVV39X+rCP5SGnVQuco7QOr19FRNGMIrD8d3LYhHWV9j9sKLzq1aDWWQQ==", + "version": "4.24.10", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.10.tgz", + "integrity": "sha512-8NGqiRO1GXBcVfV8tbbGcUgQkAGsX4GRzzXXea4lDikAsJtD5KiEY34bfhUOjHLvr6rT6afpcxw2H8EZqOV6aQ==", "license": "ISC", "dependencies": { "@babel/runtime": "^7.20.13", "@panva/hkdf": "^1.0.2", - "cookie": "^0.5.0", + "cookie": "^0.7.0", "jose": "^4.15.5", "oauth": "^0.9.15", "openid-client": "^5.4.0", @@ -4097,21 +4517,25 @@ "uuid": "^8.3.2" }, "peerDependencies": { - "next": "^12.2.5 || ^13 || ^14", + "@auth/core": "0.34.2", + "next": "^12.2.5 || ^13 || ^14 || ^15", "nodemailer": "^6.6.5", "react": "^17.0.2 || ^18", "react-dom": "^17.0.2 || ^18" }, "peerDependenciesMeta": { + "@auth/core": { + "optional": true + }, "nodemailer": { "optional": true } } }, "node_modules/next-auth/node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -4419,7 +4843,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -4428,6 +4851,24 @@ "node": ">=6" } }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4489,7 +4930,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4850,7 +5290,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -4934,9 +5373,45 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, + "node_modules/react-select": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.8.3.tgz", + "integrity": "sha512-lVswnIq8/iTj1db7XCG74M/3fbGB6ZaluCzvwPGT5ZOjCdL/k0CLWhEK0vCBLuU5bHTEf6Gj8jtSvi+3v+tO1w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.0", + "@emotion/cache": "^11.4.0", + "@emotion/react": "^11.8.1", + "@floating-ui/dom": "^1.0.1", + "@types/react-transition-group": "^4.4.0", + "memoize-one": "^6.0.0", + "prop-types": "^15.6.0", + "react-transition-group": "^4.3.0", + "use-isomorphic-layout-effect": "^1.1.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -5026,7 +5501,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -5299,6 +5773,15 @@ "node": ">=8" } }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", @@ -5566,6 +6049,12 @@ } } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -5909,6 +6398,20 @@ "punycode": "^2.1.0" } }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz", + "integrity": "sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/ladderly-io/package.json b/ladderly-io/package.json index 22db408a..69341014 100644 --- a/ladderly-io/package.json +++ b/ladderly-io/package.json @@ -29,6 +29,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-final-form": "^6.5.9", + "react-select": "^5.8.3", "server-only": "^0.0.1", "superjson": "^2.2.1", "zod": "^3.23.3" From 517518616aadf0c3adadc831d0e158ab1c5a69f4 Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Sun, 10 Nov 2024 20:07:59 -0500 Subject: [PATCH 10/31] feat: tell user their ID --- ladderly-io/src/app/settings/page.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ladderly-io/src/app/settings/page.tsx b/ladderly-io/src/app/settings/page.tsx index 021e4353..9be30fe8 100644 --- a/ladderly-io/src/app/settings/page.tsx +++ b/ladderly-io/src/app/settings/page.tsx @@ -5,6 +5,7 @@ import { LargeCard } from "~/app/core/components/LargeCard"; import { LadderlyPageWrapper } from "~/app/core/components/page-wrapper/LadderlyPageWrapper"; import { SettingsFormWrapper } from "./components/SettingsFormWrapper"; import { redirect } from "next/navigation"; +import { PaymentTierEnum } from "@prisma/client"; export const metadata = { title: "Settings", @@ -13,6 +14,7 @@ export const metadata = { export default async function SettingsPage() { try { const settings = await api.user.getSettings(); + const isPremium = settings.subscription.tier === PaymentTierEnum.FREE; return ( <LadderlyPageWrapper> @@ -22,6 +24,10 @@ export default async function SettingsPage() { Edit User Settings </h1> <p className="mt-4"> + Welcome, User ID {settings.id}!{" "} + {isPremium + ? "You are signed in to a free account." + : "You are signed in to a premium account."}{" "} Please email john@ladderly.io to update your subscription tier. </p> From a56b27df266259715bc79baec3d8692938fb756f Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Sun, 10 Nov 2024 20:36:35 -0500 Subject: [PATCH 11/31] feat: LadderlySession --- ladderly-io/src/app/home/HomePageContent.tsx | 34 ++++------- ladderly-io/src/app/page.tsx | 12 ++-- ladderly-io/src/server/api/routers/user.ts | 63 +++++++++++++++++--- ladderly-io/src/server/auth.ts | 62 ++++++++++++------- 4 files changed, 117 insertions(+), 54 deletions(-) diff --git a/ladderly-io/src/app/home/HomePageContent.tsx b/ladderly-io/src/app/home/HomePageContent.tsx index bf076583..a12b698f 100644 --- a/ladderly-io/src/app/home/HomePageContent.tsx +++ b/ladderly-io/src/app/home/HomePageContent.tsx @@ -3,17 +3,17 @@ import { PaymentTierEnum } from "@prisma/client"; import Image from "next/image"; import Link from "next/link"; -import { Suspense } from "react"; import React from "react"; -import useSubscriptionLevel from "src/app/users/hooks/useSubscriptionLevel"; import { LadderlyPageWrapper } from "~/app/core/components/page-wrapper/LadderlyPageWrapper"; import PricingGrid from "~/app/core/components/pricing-grid/PricingGrid"; -import styles from "~/styles/Home.module.css"; +import { LadderlySession } from "~/server/auth"; import { AuthButtons } from "../_components/AuthButtons"; import { LadderlyHelpsBlock } from "./LadderlyHelpsBlock"; import { TestimonialBlock } from "./TestimonialBlock"; +import styles from "~/styles/Home.module.css"; + // note: do not extract `DARK_MODE_STANDARD_CLASSES` out of file. // it is duplicated intentionally between files to ensure tailwind classes are bundled const DARK_MODE_STANDARD_CLASSES = @@ -25,11 +25,12 @@ const HomePageCardSubheading = ({ children: React.ReactNode; }) => <h2 className="mb-2 text-xl font-bold">{children}</h2>; -const AdvancedChecklistContentBlock = () => { - // const { tier } = useSubscriptionLevel(); - // TODO: get subscription tier from user router - // const isPaid = tier != PaymentTierEnum.FREE; - const isPaid = true; +const AdvancedChecklistContentBlock = ({ + session, +}: { + session: LadderlySession | null; +}) => { + const isPaid = session?.user.subscription.tier !== PaymentTierEnum.FREE; return isPaid ? ( <div @@ -49,8 +50,8 @@ const AdvancedChecklistContentBlock = () => { ) : null; }; -const HomePageContent = ({ hello, session }: { hello: any; session: any }) => ( - <LadderlyPageWrapper title="Home"> +const HomePageContent = ({ session }: { session: LadderlySession | null }) => ( + <LadderlyPageWrapper> <main style={{ padding: "0rem 1rem" }}> <div className={styles.wrapper}> <div @@ -139,19 +140,10 @@ const HomePageContent = ({ hello, session }: { hello: any; session: any }) => ( </div> <section id="deleteme"> - <div className="flex flex-col items-center gap-2"> - <p className="text-2xl text-white"> - {hello ? hello.greeting : "Loading tRPC query..."} - </p> - - <AuthButtons initialSession={session} /> - </div> + <AuthButtons initialSession={session} /> </section> - <Suspense> - <AdvancedChecklistContentBlock /> - </Suspense> - + <AdvancedChecklistContentBlock session={session} /> <PricingGrid /> </div> </div> diff --git a/ladderly-io/src/app/page.tsx b/ladderly-io/src/app/page.tsx index 44280745..6539d493 100644 --- a/ladderly-io/src/app/page.tsx +++ b/ladderly-io/src/app/page.tsx @@ -1,18 +1,18 @@ import { Suspense } from "react"; +import { getServerAuthSession, LadderlySession } from "~/server/auth"; import HomePageContent from "./home/HomePageContent"; import HomePageSkeleton from "./home/HomePageSkeleton"; -import { getServerAuthSession } from "~/server/auth"; -import { api } from "~/trpc/server"; +export const metadata = { + title: "Home", +}; export default async function HomePage() { - // TODO: replace hello with subscription level - const hello = await api.post.hello({ text: "from tRPC" }); - const session = await getServerAuthSession(); + const session: LadderlySession | null = await getServerAuthSession(); return ( <Suspense fallback={<HomePageSkeleton />}> - <HomePageContent hello={hello} session={session} /> + <HomePageContent session={session} /> </Suspense> ); } diff --git a/ladderly-io/src/server/api/routers/user.ts b/ladderly-io/src/server/api/routers/user.ts index fc661898..9f004363 100644 --- a/ladderly-io/src/server/api/routers/user.ts +++ b/ladderly-io/src/server/api/routers/user.ts @@ -13,12 +13,13 @@ const tiersOrder = { PREMIUM: 2, } as const; -const UpdateSettingsSchema = z.object({ +// Define the settings input type +export const UpdateSettingsSchema = z.object({ email: z.string(), - emailBackup: z.string().optional(), - emailStripe: z.string().optional(), - nameFirst: z.string().optional(), - nameLast: z.string().optional(), + emailBackup: z.string().nullable(), + emailStripe: z.string().nullable(), + nameFirst: z.string().nullable(), + nameLast: z.string().nullable(), hasOpenToWork: z.boolean(), hasShoutOutsEnabled: z.boolean(), profileBlurb: z.string().nullable(), @@ -35,6 +36,36 @@ const UpdateSettingsSchema = z.object({ hasInPersonEventInterest: z.boolean(), }); +// Define the settings output type +export const UserSettingsSchema = z.object({ + id: z.number(), + email: z.string(), + emailBackup: z.string().nullable(), + emailStripe: z.string().nullable(), + nameFirst: z.string().nullable(), + nameLast: z.string().nullable(), + hasOpenToWork: z.boolean(), + hasShoutOutsEnabled: z.boolean(), + profileBlurb: z.string().nullable(), + profileContactEmail: z.string().nullable(), + profileGitHubUri: z.string().nullable(), + profileHomepageUri: z.string().nullable(), + profileLinkedInUri: z.string().nullable(), + residenceCountry: z.string(), + residenceUSState: z.string(), + hasPublicProfileEnabled: z.boolean(), + hasSmallGroupInterest: z.boolean(), + hasLiveStreamInterest: z.boolean(), + hasOnlineEventInterest: z.boolean(), + hasInPersonEventInterest: z.boolean(), + subscription: z.object({ + tier: z.nativeEnum(PaymentTierEnum), + type: z.string(), + }), +}); + +export type UserSettings = z.infer<typeof UserSettingsSchema>; + export const userRouter = createTRPCRouter({ getCurrentUser: publicProcedure.query(async ({ ctx }) => { if (!ctx.session) { @@ -235,7 +266,7 @@ export const userRouter = createTRPCRouter({ }); } - return { + const settings: UserSettings = { id: user.id, email: user.email, emailBackup: user.emailBackup, @@ -261,6 +292,8 @@ export const userRouter = createTRPCRouter({ type: subscription.type, }, }; + + return UserSettingsSchema.parse(settings); }); return result; @@ -312,8 +345,24 @@ export const userRouter = createTRPCRouter({ residenceCountry: input.residenceCountry?.trim() || '', residenceUSState: input.residenceUSState?.trim() || '', }, + include: { + subscriptions: { + where: { type: 'ACCOUNT_PLAN' }, + select: { tier: true, type: true }, + }, + }, }); - return user; + const subscription = user.subscriptions[0] || { + tier: PaymentTierEnum.FREE, + type: 'ACCOUNT_PLAN', + }; + + const settings: UserSettings = { + ...user, + subscription, + }; + + return UserSettingsSchema.parse(settings); }), }); diff --git a/ladderly-io/src/server/auth.ts b/ladderly-io/src/server/auth.ts index 5effd60f..3ae40b0c 100644 --- a/ladderly-io/src/server/auth.ts +++ b/ladderly-io/src/server/auth.ts @@ -7,36 +7,58 @@ import DiscordProvider from "next-auth/providers/discord"; import GithubProvider from "next-auth/providers/github"; import GoogleProvider from "next-auth/providers/google"; import LinkedInProvider from "next-auth/providers/linkedin"; +import { PaymentTierEnum } from "@prisma/client"; import { env } from "~/env"; import { db } from "~/server/db"; import { LadderlyMigrationAdapter } from "./LadderlyMigrationAdapter"; -declare module "next-auth" { - interface Session extends DefaultSession { - user: { - id: string; - // ...other properties - // role: UserRole; - } & DefaultSession["user"]; +export interface LadderlySession extends DefaultSession { + user: { + id: string; + subscription: { + tier: PaymentTierEnum; + type: string; + }; + email?: string | null; + name?: string | null; + image?: string | null; } +} - // interface User { - // // ...other properties - // // role: UserRole; - // } +declare module "next-auth" { + interface Session extends LadderlySession {} } export const authOptions: NextAuthOptions = { callbacks: { - session: ({ session, user }) => ({ - ...session, - user: { - ...session.user, - id: user.id, - }, - }), + session: async ({ session, user }): Promise<LadderlySession> => { + const dbUser = await db.user.findUnique({ + where: { id: parseInt(user.id) }, + include: { + subscriptions: { + where: { type: 'ACCOUNT_PLAN' }, + select: { tier: true, type: true }, + }, + }, + }); + + const subscription = dbUser?.subscriptions[0] || { + tier: PaymentTierEnum.FREE, + type: 'ACCOUNT_PLAN', + }; + + return { + ...session, + user: { + ...session.user, + id: user.id, + subscription, + }, + }; + }, signIn: async ({ user, account, profile, email, credentials }) => { + // If the user exists but doesn't have an account for this provider if (account?.provider && user.email) { const existingUser = await db.user.findUnique({ where: { email: user.email }, @@ -44,7 +66,6 @@ export const authOptions: NextAuthOptions = { }); if (existingUser) { - // If the user exists but doesn't have an account for this provider const existingAccount = existingUser.accounts.find( (acc) => acc.provider === account.provider ); @@ -107,4 +128,5 @@ export const authOptions: NextAuthOptions = { ].filter(Boolean) as NextAuthOptions["providers"], }; -export const getServerAuthSession = () => getServerSession(authOptions); +export const getServerAuthSession = () => + getServerSession(authOptions) as Promise<LadderlySession | null>; From a577b046070c637b72f094450a97c689561ccfa5 Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Sun, 10 Nov 2024 21:23:09 -0500 Subject: [PATCH 12/31] feat: auth router --- .../src/app/(auth)/components/LoginForm.tsx | 70 +++++++++++++++++++ ladderly-io/src/app/(auth)/login/page.tsx | 32 +++++++++ ladderly-io/src/server/api/root.ts | 2 + ladderly-io/src/server/api/routers/auth.ts | 35 ++++++++++ 4 files changed, 139 insertions(+) create mode 100644 ladderly-io/src/app/(auth)/components/LoginForm.tsx create mode 100644 ladderly-io/src/app/(auth)/login/page.tsx create mode 100644 ladderly-io/src/server/api/routers/auth.ts diff --git a/ladderly-io/src/app/(auth)/components/LoginForm.tsx b/ladderly-io/src/app/(auth)/components/LoginForm.tsx new file mode 100644 index 00000000..17ad3c71 --- /dev/null +++ b/ladderly-io/src/app/(auth)/components/LoginForm.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { signIn } from "next-auth/react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Form } from "~/app/core/components/Form"; +import { LabeledTextField } from "~/app/core/components/LabeledTextField"; +import { Login as LoginSchema } from "~/app/(auth)/schemas"; +import { FORM_ERROR } from "final-form"; + +export const LoginForm = () => { + const router = useRouter(); + + return ( + <div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md"> + <h1 className="mb-4 text-2xl font-bold text-gray-800">Log In</h1> + + <Form + className="space-y-4" + submitText="Log In" + schema={LoginSchema} + initialValues={{ email: "", password: "" }} + onSubmit={async (values) => { + try { + const result = await signIn("credentials", { + email: values.email, + password: values.password, + redirect: false, + }); + + if (result?.error) { + return { [FORM_ERROR]: "Invalid email or password" }; + } + + if (result?.ok) { + router.push("/"); + router.refresh(); + } + } catch (error: any) { + return { + [FORM_ERROR]: "An unexpected error occurred. Please try again.", + }; + } + }} + > + <LabeledTextField name="email" label="Email" placeholder="Email" /> + <LabeledTextField + name="password" + label="Password" + placeholder="Password" + type="password" + /> + <div className="mt-4 text-left"> + <Link className="underline" href="/forgot-password"> + Forgot your password? + </Link> + </div> + </Form> + + <div className="mt-4"> + Need to create an account?{" "} + <Link className="underline" href="/signup"> + Sign up here! + </Link> + </div> + </div> + ); +}; + +export default LoginForm; diff --git a/ladderly-io/src/app/(auth)/login/page.tsx b/ladderly-io/src/app/(auth)/login/page.tsx new file mode 100644 index 00000000..6fcc7f1c --- /dev/null +++ b/ladderly-io/src/app/(auth)/login/page.tsx @@ -0,0 +1,32 @@ +// import { redirect } from "next/navigation"; +import Link from "next/link"; +import LoginForm from "src/app/(auth)/components/LoginForm"; +// import Layout from 'src/core/layouts/Layout' + +export const metadata = { + title: "Log In", +}; + +const LoginPage = () => { + // const router = useRouter(); + + return ( + // <Layout title="Log In"> + <div className="relative min-h-screen"> + <nav className="flex border border-ladderly-light-purple-1 bg-ladderly-off-white px-4 py-1 text-ladderly-violet-700"> + <Link + href="/" + className="ml-auto text-gray-800 hover:text-ladderly-pink" + > + Back to Home + </Link> + </nav> + <div className="flex min-h-[calc(100vh-4rem)] items-center justify-center"> + <LoginForm /> + </div> + </div> + // </Layout> + ); +}; + +export default LoginPage; diff --git a/ladderly-io/src/server/api/root.ts b/ladderly-io/src/server/api/root.ts index 67b4a0dd..5f89842c 100644 --- a/ladderly-io/src/server/api/root.ts +++ b/ladderly-io/src/server/api/root.ts @@ -1,6 +1,7 @@ import { postRouter } from "~/server/api/routers/post"; import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc"; import { userRouter } from "./routers/user"; +import { authRouter } from "./routers/auth"; /** * This is the primary router for your server. @@ -10,6 +11,7 @@ import { userRouter } from "./routers/user"; export const appRouter = createTRPCRouter({ post: postRouter, user: userRouter, + auth: authRouter, }); // export type definition of API diff --git a/ladderly-io/src/server/api/routers/auth.ts b/ladderly-io/src/server/api/routers/auth.ts new file mode 100644 index 00000000..aede8f5d --- /dev/null +++ b/ladderly-io/src/server/api/routers/auth.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; +import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; +import { TRPCError } from '@trpc/server'; + +export const LoginSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), +}); + +export const authRouter = createTRPCRouter({ + validateCredentials: publicProcedure + .input(LoginSchema) + .mutation(async ({ ctx, input }) => { + const { email } = input; + + const user = await ctx.db.user.findFirst({ + where: { email }, + select: { + id: true, + email: true, + nameFirst: true, + nameLast: true, + } + }); + + if (!user) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Invalid email or password', + }); + } + + return user; + }), +}); \ No newline at end of file From 474460e07e741a6a4a7fff57ae4d229ffe7359b3 Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Sun, 10 Nov 2024 21:36:07 -0500 Subject: [PATCH 13/31] feat: SocialSignIn --- .../src/app/(auth)/components/LoginForm.tsx | 12 +++--- ladderly-io/src/app/(auth)/schemas.ts | 42 +++++++++++++++++++ .../src/app/_components/AuthButtons.tsx | 34 --------------- .../src/app/_components/SocialSignIn.tsx | 34 +++++++++++++++ ladderly-io/src/app/home/HomePageContent.tsx | 5 --- 5 files changed, 83 insertions(+), 44 deletions(-) create mode 100644 ladderly-io/src/app/(auth)/schemas.ts delete mode 100644 ladderly-io/src/app/_components/AuthButtons.tsx create mode 100644 ladderly-io/src/app/_components/SocialSignIn.tsx diff --git a/ladderly-io/src/app/(auth)/components/LoginForm.tsx b/ladderly-io/src/app/(auth)/components/LoginForm.tsx index 17ad3c71..08da8d50 100644 --- a/ladderly-io/src/app/(auth)/components/LoginForm.tsx +++ b/ladderly-io/src/app/(auth)/components/LoginForm.tsx @@ -1,20 +1,24 @@ "use client"; +import { FORM_ERROR } from "final-form"; import { signIn } from "next-auth/react"; import Link from "next/link"; import { useRouter } from "next/navigation"; +import { Login as LoginSchema } from "~/app/(auth)/schemas"; +import { SocialSignIn } from "~/app/_components/SocialSignIn"; import { Form } from "~/app/core/components/Form"; import { LabeledTextField } from "~/app/core/components/LabeledTextField"; -import { Login as LoginSchema } from "~/app/(auth)/schemas"; -import { FORM_ERROR } from "final-form"; +import { LadderlySession } from "~/server/auth"; -export const LoginForm = () => { +export const LoginForm = ({ session }: { session: LadderlySession | null }) => { const router = useRouter(); return ( <div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md"> <h1 className="mb-4 text-2xl font-bold text-gray-800">Log In</h1> + <SocialSignIn initialSession={session} /> + <Form className="space-y-4" submitText="Log In" @@ -66,5 +70,3 @@ export const LoginForm = () => { </div> ); }; - -export default LoginForm; diff --git a/ladderly-io/src/app/(auth)/schemas.ts b/ladderly-io/src/app/(auth)/schemas.ts new file mode 100644 index 00000000..62e5e8ae --- /dev/null +++ b/ladderly-io/src/app/(auth)/schemas.ts @@ -0,0 +1,42 @@ +import { z } from 'zod' + +export const email = z + .string() + .email() + .transform((str) => str.toLowerCase().trim()) + +export const password = z + .string() + .min(10) + .max(100) + .transform((str) => str.trim()) + +export const Signup = z.object({ + email, + password, +}) + +export const Login = z.object({ + email, + password: z.string(), +}) + +export const ForgotPassword = z.object({ + email, +}) + +export const ResetPassword = z + .object({ + password: password, + passwordConfirmation: password, + token: z.string(), + }) + .refine((data) => data.password === data.passwordConfirmation, { + message: "Passwords don't match", + path: ['passwordConfirmation'], // set the path of the error + }) + +export const ChangePassword = z.object({ + currentPassword: z.string(), + newPassword: password, +}) diff --git a/ladderly-io/src/app/_components/AuthButtons.tsx b/ladderly-io/src/app/_components/AuthButtons.tsx deleted file mode 100644 index 5ba5025b..00000000 --- a/ladderly-io/src/app/_components/AuthButtons.tsx +++ /dev/null @@ -1,34 +0,0 @@ -"use client"; - -import { signIn, signOut } from "next-auth/react"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; - -export function AuthButtons({ initialSession }: { initialSession: any }) { - const [session, setSession] = useState(initialSession); - const router = useRouter(); - - const handleSignIn = async () => { - await signIn(undefined, { callbackUrl: "/" }); - }; - - const handleSignOut = async () => { - const data = await signOut({ redirect: false, callbackUrl: "/" }); - setSession(null); - router.push(data.url); - }; - - return ( - <div className="flex flex-col items-center justify-center gap-4"> - <p className="text-center text-2xl text-white"> - {session && <span>Logged in as {session.user?.name}</span>} - </p> - <button - onClick={session ? handleSignOut : handleSignIn} - className="rounded-full bg-white/10 px-10 py-3 font-semibold no-underline transition hover:bg-white/20" - > - {session ? "Sign out" : "Sign in"} - </button> - </div> - ); -} diff --git a/ladderly-io/src/app/_components/SocialSignIn.tsx b/ladderly-io/src/app/_components/SocialSignIn.tsx new file mode 100644 index 00000000..861980b4 --- /dev/null +++ b/ladderly-io/src/app/_components/SocialSignIn.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { signIn, signOut } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { LadderlySession } from "~/server/auth"; + +export function SocialSignIn({ + initialSession, +}: { + initialSession: LadderlySession | null; +}) { + const [session, setSession] = useState(initialSession); + const router = useRouter(); + + const handleSignIn = async () => { + await signIn(undefined, { callbackUrl: "/" }); + }; + + const handleSignOut = async () => { + const data = await signOut({ redirect: false, callbackUrl: "/" }); + setSession(null); + router.push(data.url); + }; + + return ( + <button + onClick={session ? handleSignOut : handleSignIn} + className="rounded-full bg-gray-200 px-10 p-2 my-2 font-semibold no-underline transition hover:bg-gray-800 hover:text-white" + > + {session ? "Sign out" : "Social Sign in"} + </button> + ); +} diff --git a/ladderly-io/src/app/home/HomePageContent.tsx b/ladderly-io/src/app/home/HomePageContent.tsx index a12b698f..b2ddf181 100644 --- a/ladderly-io/src/app/home/HomePageContent.tsx +++ b/ladderly-io/src/app/home/HomePageContent.tsx @@ -8,7 +8,6 @@ import React from "react"; import { LadderlyPageWrapper } from "~/app/core/components/page-wrapper/LadderlyPageWrapper"; import PricingGrid from "~/app/core/components/pricing-grid/PricingGrid"; import { LadderlySession } from "~/server/auth"; -import { AuthButtons } from "../_components/AuthButtons"; import { LadderlyHelpsBlock } from "./LadderlyHelpsBlock"; import { TestimonialBlock } from "./TestimonialBlock"; @@ -139,10 +138,6 @@ const HomePageContent = ({ session }: { session: LadderlySession | null }) => ( </section> </div> - <section id="deleteme"> - <AuthButtons initialSession={session} /> - </section> - <AdvancedChecklistContentBlock session={session} /> <PricingGrid /> </div> From 73a8f5da9f09e62f3d62081d01354fc2e04bc15a Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Sun, 10 Nov 2024 21:44:46 -0500 Subject: [PATCH 14/31] feat: pass session from login page --- ladderly-io/src/app/(auth)/login/page.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/ladderly-io/src/app/(auth)/login/page.tsx b/ladderly-io/src/app/(auth)/login/page.tsx index 6fcc7f1c..caa06740 100644 --- a/ladderly-io/src/app/(auth)/login/page.tsx +++ b/ladderly-io/src/app/(auth)/login/page.tsx @@ -1,17 +1,18 @@ -// import { redirect } from "next/navigation"; import Link from "next/link"; -import LoginForm from "src/app/(auth)/components/LoginForm"; -// import Layout from 'src/core/layouts/Layout' +import { LoginForm } from "~/app/(auth)/components/LoginForm"; +import { getServerAuthSession } from "~/server/auth"; +import { LadderlySession } from "~/server/auth"; export const metadata = { title: "Log In", }; -const LoginPage = () => { - // const router = useRouter(); +const LoginPage = async () => { + const session: LadderlySession | null = await getServerAuthSession(); + + // TODO: tell user if they are already logged in return ( - // <Layout title="Log In"> <div className="relative min-h-screen"> <nav className="flex border border-ladderly-light-purple-1 bg-ladderly-off-white px-4 py-1 text-ladderly-violet-700"> <Link @@ -22,10 +23,9 @@ const LoginPage = () => { </Link> </nav> <div className="flex min-h-[calc(100vh-4rem)] items-center justify-center"> - <LoginForm /> + <LoginForm session={session} /> </div> </div> - // </Layout> ); }; From ee36a3070e731850eed9eb989a304ca6629080db Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Sun, 10 Nov 2024 21:52:22 -0500 Subject: [PATCH 15/31] feat: easier login w goog --- .../src/app/(auth)/components/LoginForm.tsx | 27 ++++++++++++--- ladderly-io/src/app/_components/.gitkeep | 0 .../src/app/_components/SocialSignIn.tsx | 34 ------------------- 3 files changed, 22 insertions(+), 39 deletions(-) delete mode 100644 ladderly-io/src/app/_components/.gitkeep delete mode 100644 ladderly-io/src/app/_components/SocialSignIn.tsx diff --git a/ladderly-io/src/app/(auth)/components/LoginForm.tsx b/ladderly-io/src/app/(auth)/components/LoginForm.tsx index 08da8d50..db2ef2d4 100644 --- a/ladderly-io/src/app/(auth)/components/LoginForm.tsx +++ b/ladderly-io/src/app/(auth)/components/LoginForm.tsx @@ -5,23 +5,40 @@ import { signIn } from "next-auth/react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { Login as LoginSchema } from "~/app/(auth)/schemas"; -import { SocialSignIn } from "~/app/_components/SocialSignIn"; import { Form } from "~/app/core/components/Form"; import { LabeledTextField } from "~/app/core/components/LabeledTextField"; -import { LadderlySession } from "~/server/auth"; -export const LoginForm = ({ session }: { session: LadderlySession | null }) => { +export const LoginForm = () => { const router = useRouter(); return ( <div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md"> <h1 className="mb-4 text-2xl font-bold text-gray-800">Log In</h1> - <SocialSignIn initialSession={session} /> + <button + onClick={() => signIn("google", { callbackUrl: "/" })} + className="mb-6 flex w-full items-center justify-center gap-2 rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-700 shadow-sm hover:bg-gray-50" + > + <img + src="https://www.google.com/favicon.ico" + alt="Google" + className="h-5 w-5" + /> + Sign in with Google + </button> + + <div className="relative mb-6"> + <div className="absolute inset-0 flex items-center"> + <div className="w-full border-t border-gray-300"></div> + </div> + <div className="relative flex justify-center text-sm"> + <span className="bg-white px-2 text-gray-500">Or continue with</span> + </div> + </div> <Form className="space-y-4" - submitText="Log In" + submitText="Log In with Email" schema={LoginSchema} initialValues={{ email: "", password: "" }} onSubmit={async (values) => { diff --git a/ladderly-io/src/app/_components/.gitkeep b/ladderly-io/src/app/_components/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/ladderly-io/src/app/_components/SocialSignIn.tsx b/ladderly-io/src/app/_components/SocialSignIn.tsx deleted file mode 100644 index 861980b4..00000000 --- a/ladderly-io/src/app/_components/SocialSignIn.tsx +++ /dev/null @@ -1,34 +0,0 @@ -"use client"; - -import { signIn, signOut } from "next-auth/react"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { LadderlySession } from "~/server/auth"; - -export function SocialSignIn({ - initialSession, -}: { - initialSession: LadderlySession | null; -}) { - const [session, setSession] = useState(initialSession); - const router = useRouter(); - - const handleSignIn = async () => { - await signIn(undefined, { callbackUrl: "/" }); - }; - - const handleSignOut = async () => { - const data = await signOut({ redirect: false, callbackUrl: "/" }); - setSession(null); - router.push(data.url); - }; - - return ( - <button - onClick={session ? handleSignOut : handleSignIn} - className="rounded-full bg-gray-200 px-10 p-2 my-2 font-semibold no-underline transition hover:bg-gray-800 hover:text-white" - > - {session ? "Sign out" : "Social Sign in"} - </button> - ); -} From 0ca762b891b72cdfebc047e150521b2836ffb786 Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Thu, 14 Nov 2024 22:53:57 -0500 Subject: [PATCH 16/31] feat: tell ppl if they're already logged in --- ladderly-io/src/app/(auth)/login/page.tsx | 4 +--- ladderly-io/src/server/auth.ts | 22 ++++++++++++++-------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/ladderly-io/src/app/(auth)/login/page.tsx b/ladderly-io/src/app/(auth)/login/page.tsx index caa06740..b148b231 100644 --- a/ladderly-io/src/app/(auth)/login/page.tsx +++ b/ladderly-io/src/app/(auth)/login/page.tsx @@ -10,8 +10,6 @@ export const metadata = { const LoginPage = async () => { const session: LadderlySession | null = await getServerAuthSession(); - // TODO: tell user if they are already logged in - return ( <div className="relative min-h-screen"> <nav className="flex border border-ladderly-light-purple-1 bg-ladderly-off-white px-4 py-1 text-ladderly-violet-700"> @@ -23,7 +21,7 @@ const LoginPage = async () => { </Link> </nav> <div className="flex min-h-[calc(100vh-4rem)] items-center justify-center"> - <LoginForm session={session} /> + {session?.user ? <p>You are already logged in.</p> : <LoginForm />} </div> </div> ); diff --git a/ladderly-io/src/server/auth.ts b/ladderly-io/src/server/auth.ts index 3ae40b0c..e9bd4dbc 100644 --- a/ladderly-io/src/server/auth.ts +++ b/ladderly-io/src/server/auth.ts @@ -14,14 +14,14 @@ import { db } from "~/server/db"; import { LadderlyMigrationAdapter } from "./LadderlyMigrationAdapter"; export interface LadderlySession extends DefaultSession { - user: { + user?: { id: string; subscription: { tier: PaymentTierEnum; type: string; }; - email?: string | null; - name?: string | null; + email: string | null; + name: string | null; image?: string | null; } } @@ -48,13 +48,19 @@ export const authOptions: NextAuthOptions = { type: 'ACCOUNT_PLAN', }; + const userData = dbUser + ? { + email: dbUser.email, + name: dbUser.name, + image: dbUser.image, + id: user.id, + subscription, + } + : undefined; + return { ...session, - user: { - ...session.user, - id: user.id, - subscription, - }, + user: userData, }; }, signIn: async ({ user, account, profile, email, credentials }) => { From d6da6347707aab03d9a16e1d3fedd5a6eed42f22 Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Thu, 14 Nov 2024 22:57:48 -0500 Subject: [PATCH 17/31] feat: forgot pass page visible --- .../src/app/(auth)/forgot-password/page.tsx | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 ladderly-io/src/app/(auth)/forgot-password/page.tsx diff --git a/ladderly-io/src/app/(auth)/forgot-password/page.tsx b/ladderly-io/src/app/(auth)/forgot-password/page.tsx new file mode 100644 index 00000000..4ea1c4f1 --- /dev/null +++ b/ladderly-io/src/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { LabeledTextField } from "~/app/core/components/LabeledTextField"; +import { Form, FORM_ERROR } from "~/app/core/components/Form"; +import { ForgotPassword } from "src/app/(auth)/schemas"; +// import forgotPassword from "src/app/(auth)/mutations/forgotPassword"; +// import { useMutation } from "@blitzjs/rpc"; +import Link from "next/link"; + +const ForgotPasswordPage = () => { + // const [forgotPasswordMutation, { isSuccess }] = useMutation(forgotPassword); + const isSuccess = false; + + return ( + <div className="relative min-h-screen"> + <nav className="border-ladderly-light-purple flex border bg-ladderly-off-white px-4 py-1 text-ladderly-violet-700"> + <Link + href="/" + className="ml-auto text-gray-800 hover:text-ladderly-pink" + > + Back to Home + </Link> + </nav> + <div className="flex min-h-[calc(100vh-4rem)] items-center justify-center"> + <div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md"> + <h1 className="mb-4 text-2xl font-bold text-gray-800"> + Forgot your password? + </h1> + + {isSuccess ? ( + <div> + <h2>Request Submitted</h2> + <p> + If your email is in our system, you will receive instructions to + reset your password shortly. + </p> + </div> + ) : ( + <Form + submitText="Send Reset Password Instructions" + schema={ForgotPassword} + initialValues={{ email: "" }} + onSubmit={async (values) => { + try { + // await forgotPasswordMutation(values); + } catch (error: any) { + return { + [FORM_ERROR]: + "Sorry, we had an unexpected error. Please try again.", + }; + } + }} + > + <LabeledTextField + name="email" + label="Email" + placeholder="Email" + /> + </Form> + )} + </div> + </div> + </div> + ); +}; + +export default ForgotPasswordPage; From 9dc1b16c73f30795ee98fceb4f95aff2c2599cda Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Fri, 15 Nov 2024 00:04:26 -0500 Subject: [PATCH 18/31] feat: refresh page on logout --- .../src/app/core/components/page-wrapper/TopNavSubmenu.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/ladderly-io/src/app/core/components/page-wrapper/TopNavSubmenu.tsx b/ladderly-io/src/app/core/components/page-wrapper/TopNavSubmenu.tsx index e5f16822..7c325eda 100644 --- a/ladderly-io/src/app/core/components/page-wrapper/TopNavSubmenu.tsx +++ b/ladderly-io/src/app/core/components/page-wrapper/TopNavSubmenu.tsx @@ -75,18 +75,15 @@ export const CommunityMenuItems = ({ const LogoutButton = ({ className }: { className: string }) => { const { setMenu } = React.useContext(MenuContext); - const router = useRouter(); return ( <button className={className} - onClick={async () => { + onClick={() => { setMenu(null, ""); - const signOutResponse = await signOut({ - redirect: false, + signOut({ callbackUrl: "/", }); - router.push(signOutResponse.url); }} > Log Out From 80590d64ccab66eb72f39aa0a00187c2ec4fbf1e Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Fri, 15 Nov 2024 00:08:31 -0500 Subject: [PATCH 19/31] chore: install argon --- ladderly-io/package-lock.json | 45 +++++++++++++++++++++++++++++++++++ ladderly-io/package.json | 1 + 2 files changed, 46 insertions(+) diff --git a/ladderly-io/package-lock.json b/ladderly-io/package-lock.json index d8be6657..b2c194db 100644 --- a/ladderly-io/package-lock.json +++ b/ladderly-io/package-lock.json @@ -17,6 +17,7 @@ "@trpc/client": "^11.0.0-rc.446", "@trpc/react-query": "^11.0.0-rc.446", "@trpc/server": "^11.0.0-rc.446", + "argon2": "^0.41.1", "geist": "^1.3.0", "next": "^14.2.4", "next-auth": "^4.24.7", @@ -856,6 +857,15 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1459,6 +1469,21 @@ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "license": "MIT" }, + "node_modules/argon2": { + "version": "0.41.1", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.41.1.tgz", + "integrity": "sha512-dqCW8kJXke8Ik+McUcMDltrbuAWETPyU6iq+4AhxqKphWi7pChB/Zgd/Tp/o8xRLbg8ksMj46F/vph9wnxpTzQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@phc/format": "^1.0.0", + "node-addon-api": "^8.1.0", + "node-gyp-build": "^4.8.1" + }, + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -4578,6 +4603,26 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-addon-api": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.2.2.tgz", + "integrity": "sha512-9emqXAKhVoNrQ792nLI/wpzPpJ/bj/YXxW0CvAau1+RdGBcCRF1Dmz7719zgVsQNrzHl9Tzn3ImZ4qWFarWL0A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.3.tgz", + "integrity": "sha512-EMS95CMJzdoSKoIiXo8pxKoL8DYxwIZXYlLmgPb8KUv794abpnLK6ynsCAWNliOjREKruYKdzbh76HHYUHX7nw==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/ladderly-io/package.json b/ladderly-io/package.json index 69341014..398a3d90 100644 --- a/ladderly-io/package.json +++ b/ladderly-io/package.json @@ -23,6 +23,7 @@ "@trpc/client": "^11.0.0-rc.446", "@trpc/react-query": "^11.0.0-rc.446", "@trpc/server": "^11.0.0-rc.446", + "argon2": "^0.41.1", "geist": "^1.3.0", "next": "^14.2.4", "next-auth": "^4.24.7", From 214d2c039a9b03c595c2a0d46d8159919c4ae549 Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Sun, 17 Nov 2024 23:56:35 -0500 Subject: [PATCH 20/31] forgot password wip --- ladderly-io/package-lock.json | 109 ++++++++++++++++++ ladderly-io/package.json | 1 + .../(auth)/components/ForgotPasswordForm.tsx | 45 ++++++++ .../src/app/(auth)/forgot-password/page.tsx | 10 +- ladderly-io/src/server/api/routers/auth.ts | 20 ++++ ladderly-io/src/server/auth.ts | 84 ++++++++++++++ .../server/mailers/forgotPasswordMailer.ts | 37 ++++++ 7 files changed, 300 insertions(+), 6 deletions(-) create mode 100644 ladderly-io/src/app/(auth)/components/ForgotPasswordForm.tsx create mode 100644 ladderly-io/src/server/mailers/forgotPasswordMailer.ts diff --git a/ladderly-io/package-lock.json b/ladderly-io/package-lock.json index b2c194db..c74f6c2f 100644 --- a/ladderly-io/package-lock.json +++ b/ladderly-io/package-lock.json @@ -21,6 +21,7 @@ "geist": "^1.3.0", "next": "^14.2.4", "next-auth": "^4.24.7", + "postmark": "^4.0.5", "react": "^18.3.1", "react-dom": "^18.3.1", "react-final-form": "^6.5.9", @@ -1676,6 +1677,12 @@ "dev": true, "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -1702,6 +1709,17 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/axobject-query": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz", @@ -1912,6 +1930,18 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -2169,6 +2199,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -3252,6 +3291,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -3278,6 +3337,20 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -4399,6 +4472,27 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -5181,6 +5275,15 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/postmark": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/postmark/-/postmark-4.0.5.tgz", + "integrity": "sha512-nerZdd3TwOH4CgGboZnlUM/q7oZk0EqpZgJL+Y3Nup8kHeaukxouQ6JcFF3EJEijc4QbuNv1TefGhboAKtf/SQ==", + "license": "MIT", + "dependencies": { + "axios": "^1.7.4" + } + }, "node_modules/preact": { "version": "10.11.3", "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz", @@ -5342,6 +5445,12 @@ "react-is": "^16.13.1" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/ladderly-io/package.json b/ladderly-io/package.json index 398a3d90..cc6cdf83 100644 --- a/ladderly-io/package.json +++ b/ladderly-io/package.json @@ -27,6 +27,7 @@ "geist": "^1.3.0", "next": "^14.2.4", "next-auth": "^4.24.7", + "postmark": "^4.0.5", "react": "^18.3.1", "react-dom": "^18.3.1", "react-final-form": "^6.5.9", diff --git a/ladderly-io/src/app/(auth)/components/ForgotPasswordForm.tsx b/ladderly-io/src/app/(auth)/components/ForgotPasswordForm.tsx new file mode 100644 index 00000000..dfa3b465 --- /dev/null +++ b/ladderly-io/src/app/(auth)/components/ForgotPasswordForm.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { Form, FORM_ERROR } from "~/app/core/components/Form"; +import LabeledTextField from "~/app/core/components/LabeledTextField"; +import { ForgotPassword } from "../schemas"; +import { api } from "~/trpc/react"; + +export function ForgotPasswordForm() { + const forgotPasswordMutation = api.auth.forgotPassword.useMutation(); + + return ( + <> + <h1>Forgot your password?</h1> + <> + {forgotPasswordMutation.isSuccess ? ( + <div> + <h2>Request Submitted</h2> + <p> + If your email is in our system, you will receive instructions to + reset your password shortly. + </p> + </div> + ) : ( + <Form + submitText="Send Reset Password Instructions" + schema={ForgotPassword} + initialValues={{ email: "" }} + onSubmit={async (values: any) => { + try { + await forgotPasswordMutation.mutateAsync(values); + } catch (error: any) { + return { + [FORM_ERROR]: + "Sorry, we had an unexpected error. Please try again.", + }; + } + }} + > + <LabeledTextField name="email" label="Email" placeholder="Email" /> + </Form> + )} + </> + </> + ); +} diff --git a/ladderly-io/src/app/(auth)/forgot-password/page.tsx b/ladderly-io/src/app/(auth)/forgot-password/page.tsx index 4ea1c4f1..9cc43e2a 100644 --- a/ladderly-io/src/app/(auth)/forgot-password/page.tsx +++ b/ladderly-io/src/app/(auth)/forgot-password/page.tsx @@ -3,13 +3,11 @@ import { LabeledTextField } from "~/app/core/components/LabeledTextField"; import { Form, FORM_ERROR } from "~/app/core/components/Form"; import { ForgotPassword } from "src/app/(auth)/schemas"; -// import forgotPassword from "src/app/(auth)/mutations/forgotPassword"; -// import { useMutation } from "@blitzjs/rpc"; +import { api } from "~/trpc/react"; import Link from "next/link"; const ForgotPasswordPage = () => { - // const [forgotPasswordMutation, { isSuccess }] = useMutation(forgotPassword); - const isSuccess = false; + const forgotPasswordMutation = api.auth.forgotPassword.useMutation(); return ( <div className="relative min-h-screen"> @@ -27,7 +25,7 @@ const ForgotPasswordPage = () => { Forgot your password? </h1> - {isSuccess ? ( + {forgotPasswordMutation.isSuccess ? ( <div> <h2>Request Submitted</h2> <p> @@ -42,7 +40,7 @@ const ForgotPasswordPage = () => { initialValues={{ email: "" }} onSubmit={async (values) => { try { - // await forgotPasswordMutation(values); + await forgotPasswordMutation.mutateAsync(values); } catch (error: any) { return { [FORM_ERROR]: diff --git a/ladderly-io/src/server/api/routers/auth.ts b/ladderly-io/src/server/api/routers/auth.ts index aede8f5d..062ef07e 100644 --- a/ladderly-io/src/server/api/routers/auth.ts +++ b/ladderly-io/src/server/api/routers/auth.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { TRPCError } from '@trpc/server'; +import { sendForgotPasswordEmail } from '~/server/mailers/forgotPasswordMailer'; export const LoginSchema = z.object({ email: z.string().email(), @@ -32,4 +33,23 @@ export const authRouter = createTRPCRouter({ return user; }), + + forgotPassword: publicProcedure + .input(z.object({ email: z.string().email() })) + .mutation(async ({ ctx, input }) => { + const { email } = input; + + const user = await ctx.db.user.findUnique({ where: { email } }); + + if (!user) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'If your email is in our system, you will receive instructions to reset your password shortly.', + }); + } + + await sendForgotPasswordEmail({ to: user.email, token: "generated-token" }); + + return { success: true }; + }), }); \ No newline at end of file diff --git a/ladderly-io/src/server/auth.ts b/ladderly-io/src/server/auth.ts index e9bd4dbc..94b5111a 100644 --- a/ladderly-io/src/server/auth.ts +++ b/ladderly-io/src/server/auth.ts @@ -1,3 +1,11 @@ +/* TODO: + * let's abandon compatability with blitz.js passwords and simply force users to reset passwords as part of this migration. + * I'll mark user passwords as empty strings + * If the user password in the DB is empty we will trigger the forgot password flow and inform the user + * If the user password in the DB is populated, suggest a current best practice for node.js v20+ + * Using the argon2 package seems fine to me. + */ + import { getServerSession, type DefaultSession, @@ -7,11 +15,14 @@ import DiscordProvider from "next-auth/providers/discord"; import GithubProvider from "next-auth/providers/github"; import GoogleProvider from "next-auth/providers/google"; import LinkedInProvider from "next-auth/providers/linkedin"; +import CredentialsProvider from "next-auth/providers/credentials"; import { PaymentTierEnum } from "@prisma/client"; +import * as argon2 from "argon2"; import { env } from "~/env"; import { db } from "~/server/db"; import { LadderlyMigrationAdapter } from "./LadderlyMigrationAdapter"; +import { TRPCError } from "@trpc/server"; export interface LadderlySession extends DefaultSession { user?: { @@ -30,6 +41,21 @@ declare module "next-auth" { interface Session extends LadderlySession {} } +// Helper function to hash password +async function hashPassword(password: string): Promise<string> { + return await argon2.hash(password); +} + +// Helper function to verify password +async function verifyPassword(hashedPassword: string, plaintext: string): Promise<boolean> { + try { + return await argon2.verify(hashedPassword, plaintext); + } catch (error) { + console.error('Password verification failed:', error); + return false; + } +} + export const authOptions: NextAuthOptions = { callbacks: { session: async ({ session, user }): Promise<LadderlySession> => { @@ -131,6 +157,64 @@ export const authOptions: NextAuthOptions = { clientSecret: env.LINKEDIN_CLIENT_SECRET, }) : null, + CredentialsProvider({ + name: "Credentials", + credentials: { + email: { label: "Email", type: "email", placeholder: "your-email@example.com" }, + password: { label: "Password", type: "password" }, + }, + async authorize(credentials, req) { + if (!credentials?.email || !credentials?.password) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Invalid credentials', + }); + } + + const user = await db.user.findUnique({ + where: { email: credentials.email }, + }); + + if (!user) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Invalid email or password', + }); + } + + if (!user.hashedPassword) { + // Trigger password reset flow + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Password reset required. Please check your email to reset your password.', + }); + } + + try { + const isValid = await verifyPassword(user.hashedPassword, credentials.password); + + if (!isValid) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Invalid email or password', + }); + } + + return { + id: user.id.toString(), + email: user.email, + name: `${user.nameFirst} ${user.nameLast}`.trim() || null, + image: user.image || null, + }; + } catch (error) { + console.error('Password verification failed:', error); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'An error occurred during authentication', + }); + } + }, + }), ].filter(Boolean) as NextAuthOptions["providers"], }; diff --git a/ladderly-io/src/server/mailers/forgotPasswordMailer.ts b/ladderly-io/src/server/mailers/forgotPasswordMailer.ts new file mode 100644 index 00000000..3364fcb3 --- /dev/null +++ b/ladderly-io/src/server/mailers/forgotPasswordMailer.ts @@ -0,0 +1,37 @@ +import { ServerClient } from 'postmark'; + +const postmarkClient = new ServerClient( + process.env.POSTMARK_API_KEY || '' +); + +type ResetPasswordMailer = { + to: string; + token: string; +}; + +export async function sendForgotPasswordEmail({ to, token }: ResetPasswordMailer) { + const origin = process.env.APP_ORIGIN || 'http://localhost:3000'; // Default to localhost if APP_ORIGIN is not set + const resetUrl = `${origin}/reset-password?token=${token}`; + + const html = ` + <h1>Reset Your Password</h1> + <a href="${resetUrl}"> + Click here to set a new password + </a> + `; + + const msg = { + From: 'support@ladderly.io', + To: to, + Subject: 'Your Password Reset Instructions', + HtmlBody: html, + }; + + try { + await postmarkClient.sendEmail(msg); + console.log(`Password reset email sent to ${to}`); + } catch (error) { + console.error('Failed to send password reset email:', error); + throw new Error('Failed to send password reset email'); + } +} \ No newline at end of file From 23a28867081b38bad9666679bd34c94c088b4a80 Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Mon, 18 Nov 2024 00:19:08 -0500 Subject: [PATCH 21/31] feat: reset pass --- .../src/app/(auth)/components/LoginForm.tsx | 40 +++++------- .../ResetPasswordClientPageClient.tsx | 65 +++++++++++++++++++ .../src/app/(auth)/reset-password/page.tsx | 28 ++++++++ ladderly-io/src/server/api/routers/auth.ts | 63 +++++++++++++++++- 4 files changed, 173 insertions(+), 23 deletions(-) create mode 100644 ladderly-io/src/app/(auth)/reset-password/ResetPasswordClientPageClient.tsx create mode 100644 ladderly-io/src/app/(auth)/reset-password/page.tsx diff --git a/ladderly-io/src/app/(auth)/components/LoginForm.tsx b/ladderly-io/src/app/(auth)/components/LoginForm.tsx index db2ef2d4..0681b39c 100644 --- a/ladderly-io/src/app/(auth)/components/LoginForm.tsx +++ b/ladderly-io/src/app/(auth)/components/LoginForm.tsx @@ -11,6 +11,23 @@ import { LabeledTextField } from "~/app/core/components/LabeledTextField"; export const LoginForm = () => { const router = useRouter(); + const handleSubmit = async (values: { email: string; password: string }) => { + const result = await signIn("credentials", { + redirect: false, + email: values.email, + password: values.password, + }); + + if (result?.error) { + return { [FORM_ERROR]: result.error }; + } + + if (result?.ok) { + router.push("/"); + router.refresh(); + } + }; + return ( <div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md"> <h1 className="mb-4 text-2xl font-bold text-gray-800">Log In</h1> @@ -41,28 +58,7 @@ export const LoginForm = () => { submitText="Log In with Email" schema={LoginSchema} initialValues={{ email: "", password: "" }} - onSubmit={async (values) => { - try { - const result = await signIn("credentials", { - email: values.email, - password: values.password, - redirect: false, - }); - - if (result?.error) { - return { [FORM_ERROR]: "Invalid email or password" }; - } - - if (result?.ok) { - router.push("/"); - router.refresh(); - } - } catch (error: any) { - return { - [FORM_ERROR]: "An unexpected error occurred. Please try again.", - }; - } - }} + onSubmit={handleSubmit} > <LabeledTextField name="email" label="Email" placeholder="Email" /> <LabeledTextField diff --git a/ladderly-io/src/app/(auth)/reset-password/ResetPasswordClientPageClient.tsx b/ladderly-io/src/app/(auth)/reset-password/ResetPasswordClientPageClient.tsx new file mode 100644 index 00000000..e84eaa0f --- /dev/null +++ b/ladderly-io/src/app/(auth)/reset-password/ResetPasswordClientPageClient.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { LabeledTextField } from "~/app/core/components/LabeledTextField"; +import { Form, FORM_ERROR } from "~/app/core/components/Form"; +import { ResetPassword } from "src/app/(auth)/schemas"; +import { api } from "~/trpc/react"; +import { useSearchParams } from "next/navigation"; +import Link from "next/link"; + +const ResetPasswordClientPageClient = () => { + const searchParams = useSearchParams(); + const token = searchParams?.get("token")?.toString(); + const resetPasswordMutation = api.auth.resetPassword.useMutation(); + + return ( + <> + {resetPasswordMutation.isSuccess ? ( + <div> + <h2>Password Reset Successfully</h2> + <p> + Go to the <Link href="/">homepage</Link> + </p> + </div> + ) : ( + <Form + submitText="Reset Password" + schema={ResetPassword} + initialValues={{ + password: "", + passwordConfirmation: "", + token, + }} + onSubmit={async (values) => { + try { + if (!token) throw new Error("Token is required."); + await resetPasswordMutation.mutateAsync({ + ...values, + token, + }); + } catch (error: any) { + return { + [FORM_ERROR]: + error.message || + "Sorry, we had an unexpected error. Please try again.", + }; + } + }} + > + <LabeledTextField + name="password" + label="New Password" + type="password" + /> + <LabeledTextField + name="passwordConfirmation" + label="Confirm New Password" + type="password" + /> + </Form> + )} + </> + ); +}; + +export default ResetPasswordClientPageClient; diff --git a/ladderly-io/src/app/(auth)/reset-password/page.tsx b/ladderly-io/src/app/(auth)/reset-password/page.tsx new file mode 100644 index 00000000..8a4ce2bb --- /dev/null +++ b/ladderly-io/src/app/(auth)/reset-password/page.tsx @@ -0,0 +1,28 @@ +import { Metadata } from "next"; +import ResetPasswordClientPageClient from "./ResetPasswordClientPageClient"; + +export const metadata: Metadata = { + title: "Reset Password", +}; + +const ResetPasswordPage = () => { + return ( + <div className="relative min-h-screen"> + <nav className="border-ladderly-light-purple flex border bg-ladderly-off-white px-4 py-1 text-ladderly-violet-700"> + <a href="/" className="ml-auto text-gray-800 hover:text-ladderly-pink"> + Back to Home + </a> + </nav> + <div className="flex min-h-[calc(100vh-4rem)] items-center justify-center"> + <div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md"> + <h1 className="mb-4 text-2xl font-bold text-gray-800"> + Set a New Password + </h1> + <ResetPasswordClientPageClient /> + </div> + </div> + </div> + ); +}; + +export default ResetPasswordPage; diff --git a/ladderly-io/src/server/api/routers/auth.ts b/ladderly-io/src/server/api/routers/auth.ts index 062ef07e..1a41611e 100644 --- a/ladderly-io/src/server/api/routers/auth.ts +++ b/ladderly-io/src/server/api/routers/auth.ts @@ -2,6 +2,8 @@ import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; import { TRPCError } from '@trpc/server'; import { sendForgotPasswordEmail } from '~/server/mailers/forgotPasswordMailer'; +import crypto from 'crypto'; +import * as argon2 from 'argon2'; export const LoginSchema = z.object({ email: z.string().email(), @@ -48,7 +50,66 @@ export const authRouter = createTRPCRouter({ }); } - await sendForgotPasswordEmail({ to: user.email, token: "generated-token" }); + const token = crypto.randomBytes(32).toString('hex'); + const hashedToken = crypto.createHash('sha256').update(token).digest('hex'); + const thirtyMinutes = 30 * 60 * 1000; + + await ctx.db.token.create({ + data: { + userId: user.id, + hashedToken, + type: 'RESET_PASSWORD', + expiresAt: new Date(Date.now() + thirtyMinutes), + sentTo: user.email, + }, + }); + + await sendForgotPasswordEmail({ to: user.email, token }); + + return { success: true }; + }), + + resetPassword: publicProcedure + .input(z.object({ + token: z.string(), + password: z.string().min(8), + passwordConfirmation: z.string().min(8), + })) + .mutation(async ({ ctx, input }) => { + const { token, password, passwordConfirmation } = input; + + if (password !== passwordConfirmation) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Passwords do not match', + }); + } + + const hashedToken = crypto.createHash('sha256').update(token).digest('hex'); + + const tokenRecord = await ctx.db.token.findFirst({ + where: { + hashedToken, + type: 'RESET_PASSWORD', + expiresAt: { gt: new Date() }, + }, + }); + + if (!tokenRecord) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Invalid or expired token', + }); + } + + const hashedPassword = await argon2.hash(password); + + await ctx.db.user.update({ + where: { id: tokenRecord.userId }, + data: { hashedPassword }, + }); + + await ctx.db.token.delete({ where: { id: tokenRecord.id } }); return { success: true }; }), From afb07041118c975133956227b65b7e068975e113 Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Sat, 30 Nov 2024 13:45:38 -0500 Subject: [PATCH 22/31] feat: better signin logging --- ladderly-io/src/server/api/routers/user.ts | 5 +++-- ladderly-io/src/server/auth.ts | 20 ++++++++++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/ladderly-io/src/server/api/routers/user.ts b/ladderly-io/src/server/api/routers/user.ts index 9f004363..590d2b92 100644 --- a/ladderly-io/src/server/api/routers/user.ts +++ b/ladderly-io/src/server/api/routers/user.ts @@ -68,13 +68,14 @@ export type UserSettings = z.infer<typeof UserSettingsSchema>; export const userRouter = createTRPCRouter({ getCurrentUser: publicProcedure.query(async ({ ctx }) => { - if (!ctx.session) { + const strId = ctx?.session?.user?.id; + if (!strId) { return null; } const user = await ctx.db.user.findUnique({ where: { - id: parseInt(ctx.session.user.id), + id: parseInt(strId), }, include: { subscriptions: true diff --git a/ladderly-io/src/server/auth.ts b/ladderly-io/src/server/auth.ts index 94b5111a..a14972b0 100644 --- a/ladderly-io/src/server/auth.ts +++ b/ladderly-io/src/server/auth.ts @@ -58,7 +58,12 @@ async function verifyPassword(hashedPassword: string, plaintext: string): Promis export const authOptions: NextAuthOptions = { callbacks: { + // TODO: this seems to work with google login, but not with credentials login session: async ({ session, user }): Promise<LadderlySession> => { + console.log("Session Callback Triggered"); // TODO: delete this log + console.log("Session:", session); // TODO: delete this log + console.log("User:", user); // TODO: delete this log + const dbUser = await db.user.findUnique({ where: { id: parseInt(user.id) }, include: { @@ -69,6 +74,8 @@ export const authOptions: NextAuthOptions = { }, }); + console.log("Database User:", dbUser); // TODO: delete this log + const subscription = dbUser?.subscriptions[0] || { tier: PaymentTierEnum.FREE, type: 'ACCOUNT_PLAN', @@ -84,26 +91,31 @@ export const authOptions: NextAuthOptions = { } : undefined; + console.log("User Data for Session:", userData); // TODO: delete this log + return { ...session, user: userData, }; }, signIn: async ({ user, account, profile, email, credentials }) => { - // If the user exists but doesn't have an account for this provider if (account?.provider && user.email) { const existingUser = await db.user.findUnique({ where: { email: user.email }, include: { accounts: true }, }); + console.log("Existing User:", existingUser); // TODO: delete this log + if (existingUser) { const existingAccount = existingUser.accounts.find( (acc) => acc.provider === account.provider ); + console.log("Existing Account:", existingAccount); // TODO: delete this log + if (!existingAccount) { - await db.account.create({ + const newAccount = await db.account.create({ data: { userId: existingUser.id, type: account.type, @@ -117,9 +129,13 @@ export const authOptions: NextAuthOptions = { session_state: account.session_state, }, }); + console.log(`New Account Created with User ID: ${existingUser.id} and Account ID: ${newAccount.id}`); } return true; + } else { + console.log(`User not found by email with User ID: ${user.id}`); + return false; } } From a7903b506af174b73aea28fe15659b43188461eb Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Sat, 30 Nov 2024 13:46:38 -0500 Subject: [PATCH 23/31] feat: broken signup migration --- .../src/app/(auth)/components/SignupForm.tsx | 64 +++++++++++++++++++ ladderly-io/src/app/(auth)/signup/page.tsx | 32 ++++++++++ 2 files changed, 96 insertions(+) create mode 100644 ladderly-io/src/app/(auth)/components/SignupForm.tsx create mode 100644 ladderly-io/src/app/(auth)/signup/page.tsx diff --git a/ladderly-io/src/app/(auth)/components/SignupForm.tsx b/ladderly-io/src/app/(auth)/components/SignupForm.tsx new file mode 100644 index 00000000..a236eb67 --- /dev/null +++ b/ladderly-io/src/app/(auth)/components/SignupForm.tsx @@ -0,0 +1,64 @@ +/* TODO: migrate from blitzjs to tRPC */ + +"use client"; +import { useMutation } from "@blitzjs/rpc"; +import Link from "next/link"; +import signup from "src/app/(auth)/mutations/signup"; +import { Signup } from "src/app/(auth)/schemas"; +import { Form, FORM_ERROR } from "src/core/components/Form"; +import { LabeledTextField } from "src/core/components/LabeledTextField"; + +type SignupFormProps = { + onSuccess?: () => void; +}; + +export const SignupForm = (props: SignupFormProps) => { + const [signupMutation] = useMutation(signup); + return ( + <div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md"> + <h1 className="mb-4 text-2xl font-bold text-gray-800"> + Create an Account + </h1> + + <Form + submitText="Create Account" + schema={Signup} + initialValues={{ email: "", password: "" }} + className="space-y-4" + onSubmit={async (values) => { + try { + await signupMutation(values); + props.onSuccess?.(); + } catch (error: any) { + if ( + error.code === "P2002" && + error.meta?.target?.includes("email") + ) { + // This error comes from Prisma + return { email: "This email is already being used" }; + } else { + return { [FORM_ERROR]: error.toString() }; + } + } + }} + > + <LabeledTextField name="email" label="Email" placeholder="Email" /> + <LabeledTextField + name="password" + label="Password" + placeholder="Password" + type="password" + /> + </Form> + + <div className="mt-4"> + Already signed up?{" "} + <Link className="underline" href="/login"> + Log in here! + </Link> + </div> + </div> + ); +}; + +export default SignupForm; diff --git a/ladderly-io/src/app/(auth)/signup/page.tsx b/ladderly-io/src/app/(auth)/signup/page.tsx new file mode 100644 index 00000000..56d2f919 --- /dev/null +++ b/ladderly-io/src/app/(auth)/signup/page.tsx @@ -0,0 +1,32 @@ +/* TODO: migrate from blitzjs to tRPC */ + +"use client"; +import { BlitzPage } from "@blitzjs/next"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { SignupForm } from "src/app/(auth)/components/SignupForm"; +import Layout from "src/core/layouts/Layout"; + +const CreateAccountPage: BlitzPage = () => { + const router = useRouter(); + + return ( + <Layout title="Create Account"> + <div className="relative min-h-screen"> + <nav className="border-ladderly-light-purple flex border bg-ladderly-off-white px-4 py-1 text-ladderly-violet-700"> + <Link + href="/" + className="ml-auto text-gray-800 hover:text-ladderly-pink" + > + Back to Home + </Link> + </nav> + <div className="flex min-h-[calc(100vh-4rem)] items-center justify-center"> + <SignupForm onSuccess={() => router.push("/")} /> + </div> + </div> + </Layout> + ); +}; + +export default CreateAccountPage; From a4a0fd2e2c1d21e3843dff3a58d4abcf8dd1506a Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Sun, 1 Dec 2024 14:29:16 -0500 Subject: [PATCH 24/31] feat: explicit jwt strategy --- ladderly-io/src/server/auth.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/ladderly-io/src/server/auth.ts b/ladderly-io/src/server/auth.ts index a14972b0..2de65664 100644 --- a/ladderly-io/src/server/auth.ts +++ b/ladderly-io/src/server/auth.ts @@ -41,11 +41,6 @@ declare module "next-auth" { interface Session extends LadderlySession {} } -// Helper function to hash password -async function hashPassword(password: string): Promise<string> { - return await argon2.hash(password); -} - // Helper function to verify password async function verifyPassword(hashedPassword: string, plaintext: string): Promise<boolean> { try { @@ -57,6 +52,9 @@ async function verifyPassword(hashedPassword: string, plaintext: string): Promis } export const authOptions: NextAuthOptions = { + session: { + strategy: "jwt", + }, callbacks: { // TODO: this seems to work with google login, but not with credentials login session: async ({ session, user }): Promise<LadderlySession> => { @@ -98,6 +96,10 @@ export const authOptions: NextAuthOptions = { user: userData, }; }, + jwt: async ({ token, user, account, profile }) => { + // TODO: implement or remove jwt callback + return token; + }, signIn: async ({ user, account, profile, email, credentials }) => { if (account?.provider && user.email) { const existingUser = await db.user.findUnique({ @@ -132,13 +134,13 @@ export const authOptions: NextAuthOptions = { console.log(`New Account Created with User ID: ${existingUser.id} and Account ID: ${newAccount.id}`); } - return true; } else { console.log(`User not found by email with User ID: ${user.id}`); return false; } } + console.log("signIn callback completed successfully"); // TODO: delete this log return true; }, }, From 2c59f0f89a96df878d417c3caa74d9d57790be62 Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Sun, 1 Dec 2024 14:40:26 -0500 Subject: [PATCH 25/31] fix: TRPCClientError: Invalid response or stream interrupted --- ladderly-io/src/server/api/routers/user.ts | 5 ++++- ladderly-io/src/server/constants.ts | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 ladderly-io/src/server/constants.ts diff --git a/ladderly-io/src/server/api/routers/user.ts b/ladderly-io/src/server/api/routers/user.ts index 590d2b92..78d24664 100644 --- a/ladderly-io/src/server/api/routers/user.ts +++ b/ladderly-io/src/server/api/routers/user.ts @@ -1,3 +1,5 @@ +// src/server/api/routers/user.ts + import { createTRPCRouter, protectedProcedure, @@ -6,6 +8,7 @@ import { import { PaymentTierEnum } from "@prisma/client"; import { z } from "zod"; import { TRPCError } from '@trpc/server'; +import { NULL_RESULT_TRPC_INT } from "~/server/constants"; const tiersOrder = { FREE: 0, @@ -70,7 +73,7 @@ export const userRouter = createTRPCRouter({ getCurrentUser: publicProcedure.query(async ({ ctx }) => { const strId = ctx?.session?.user?.id; if (!strId) { - return null; + return NULL_RESULT_TRPC_INT; } const user = await ctx.db.user.findUnique({ diff --git a/ladderly-io/src/server/constants.ts b/ladderly-io/src/server/constants.ts new file mode 100644 index 00000000..316f4bba --- /dev/null +++ b/ladderly-io/src/server/constants.ts @@ -0,0 +1,5 @@ +// src/server/constants.ts + +// return 0 instead of null to avoid `TRPCClientError: Invalid response or stream interrupted` +// ref: https://github.com/trpc/trpc/discussions/5919#discussioncomment-10172462 +export const NULL_RESULT_TRPC_INT = 0; From 2efbedfdb2ff57cfeb6a234746d8fdbb024ad6c7 Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Sun, 1 Dec 2024 15:07:34 -0500 Subject: [PATCH 26/31] feat: user lookup by email --- ladderly-io/src/server/auth.ts | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/ladderly-io/src/server/auth.ts b/ladderly-io/src/server/auth.ts index 2de65664..4f1ded31 100644 --- a/ladderly-io/src/server/auth.ts +++ b/ladderly-io/src/server/auth.ts @@ -56,14 +56,9 @@ export const authOptions: NextAuthOptions = { strategy: "jwt", }, callbacks: { - // TODO: this seems to work with google login, but not with credentials login - session: async ({ session, user }): Promise<LadderlySession> => { - console.log("Session Callback Triggered"); // TODO: delete this log - console.log("Session:", session); // TODO: delete this log - console.log("User:", user); // TODO: delete this log - + session: async ({ session }: { session: DefaultSession }): Promise<LadderlySession> => { const dbUser = await db.user.findUnique({ - where: { id: parseInt(user.id) }, + where: { email: session.user?.email ?? '' }, include: { subscriptions: { where: { type: 'ACCOUNT_PLAN' }, @@ -72,8 +67,6 @@ export const authOptions: NextAuthOptions = { }, }); - console.log("Database User:", dbUser); // TODO: delete this log - const subscription = dbUser?.subscriptions[0] || { tier: PaymentTierEnum.FREE, type: 'ACCOUNT_PLAN', @@ -84,13 +77,11 @@ export const authOptions: NextAuthOptions = { email: dbUser.email, name: dbUser.name, image: dbUser.image, - id: user.id, + id: dbUser.id.toString(), subscription, } : undefined; - console.log("User Data for Session:", userData); // TODO: delete this log - return { ...session, user: userData, @@ -101,21 +92,19 @@ export const authOptions: NextAuthOptions = { return token; }, signIn: async ({ user, account, profile, email, credentials }) => { + // signIn is called by both social login and credentials login + if (account?.provider && user.email) { const existingUser = await db.user.findUnique({ where: { email: user.email }, include: { accounts: true }, }); - console.log("Existing User:", existingUser); // TODO: delete this log - if (existingUser) { const existingAccount = existingUser.accounts.find( (acc) => acc.provider === account.provider ); - console.log("Existing Account:", existingAccount); // TODO: delete this log - if (!existingAccount) { const newAccount = await db.account.create({ data: { @@ -140,7 +129,6 @@ export const authOptions: NextAuthOptions = { } } - console.log("signIn callback completed successfully"); // TODO: delete this log return true; }, }, From 816b6ec6615c958b9d63e7c197cf4513009be108 Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Sun, 1 Dec 2024 15:39:01 -0500 Subject: [PATCH 27/31] feat: jwt() --- ladderly-io/src/app/home/HomePageContent.tsx | 2 +- ladderly-io/src/server/auth.ts | 73 +++++++++++--------- 2 files changed, 43 insertions(+), 32 deletions(-) diff --git a/ladderly-io/src/app/home/HomePageContent.tsx b/ladderly-io/src/app/home/HomePageContent.tsx index b2ddf181..49871357 100644 --- a/ladderly-io/src/app/home/HomePageContent.tsx +++ b/ladderly-io/src/app/home/HomePageContent.tsx @@ -29,7 +29,7 @@ const AdvancedChecklistContentBlock = ({ }: { session: LadderlySession | null; }) => { - const isPaid = session?.user.subscription.tier !== PaymentTierEnum.FREE; + const isPaid = session?.user?.subscription.tier !== PaymentTierEnum.FREE; return isPaid ? ( <div diff --git a/ladderly-io/src/server/auth.ts b/ladderly-io/src/server/auth.ts index 4f1ded31..3635f647 100644 --- a/ladderly-io/src/server/auth.ts +++ b/ladderly-io/src/server/auth.ts @@ -8,6 +8,7 @@ import { getServerSession, + Session, type DefaultSession, type NextAuthOptions, } from "next-auth"; @@ -23,6 +24,7 @@ import { env } from "~/env"; import { db } from "~/server/db"; import { LadderlyMigrationAdapter } from "./LadderlyMigrationAdapter"; import { TRPCError } from "@trpc/server"; +import { JWT } from "next-auth/jwt"; export interface LadderlySession extends DefaultSession { user?: { @@ -56,40 +58,49 @@ export const authOptions: NextAuthOptions = { strategy: "jwt", }, callbacks: { - session: async ({ session }: { session: DefaultSession }): Promise<LadderlySession> => { - const dbUser = await db.user.findUnique({ - where: { email: session.user?.email ?? '' }, - include: { - subscriptions: { - where: { type: 'ACCOUNT_PLAN' }, - select: { tier: true, type: true }, + jwt: async ({ token, user, account }) => { + // Initial sign in + if (account && user) { + token.id = user.id; + token.email = user.email; + token.name = user.name; + token.picture = user.image; + // Add any additional user data you want to include in the token + const dbUser = await db.user.findUnique({ + where: { id: parseInt(user.id) }, + include: { + subscriptions: { + where: { type: 'ACCOUNT_PLAN' }, + select: { tier: true, type: true }, + }, }, - }, - }); - - const subscription = dbUser?.subscriptions[0] || { - tier: PaymentTierEnum.FREE, - type: 'ACCOUNT_PLAN', - }; - - const userData = dbUser - ? { - email: dbUser.email, - name: dbUser.name, - image: dbUser.image, - id: dbUser.id.toString(), - subscription, - } - : undefined; - - return { + }); + token.subscription = dbUser?.subscriptions[0] || { + tier: PaymentTierEnum.FREE, + type: 'ACCOUNT_PLAN', + }; + } + return token; + }, + session: async ({ session, token }: { session: Session, token: JWT }): Promise<LadderlySession> => { + // jwt() is executed first then session() + const user = session.user as LadderlySession['user']; + const userId = user?.id?.toString() || token.id?.toString() || null; + const newSession: LadderlySession = { ...session, - user: userData, + user: userId ? { + id: userId, + email: session.user?.email || token.email?.toString() || null, + name: token.name?.toString() || null, + image: token.picture?.toString() || null, + subscription: token.subscription as { + tier: PaymentTierEnum; + type: string; + }, + } : undefined, }; - }, - jwt: async ({ token, user, account, profile }) => { - // TODO: implement or remove jwt callback - return token; + + return newSession; }, signIn: async ({ user, account, profile, email, credentials }) => { // signIn is called by both social login and credentials login From 1f93c1e2816284f4c4ade91d73bbcad5a151b287 Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Sun, 1 Dec 2024 15:55:10 -0500 Subject: [PATCH 28/31] feat: getCurrentUser by email --- ladderly-io/src/server/api/routers/user.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/ladderly-io/src/server/api/routers/user.ts b/ladderly-io/src/server/api/routers/user.ts index 78d24664..61c9f14f 100644 --- a/ladderly-io/src/server/api/routers/user.ts +++ b/ladderly-io/src/server/api/routers/user.ts @@ -71,14 +71,15 @@ export type UserSettings = z.infer<typeof UserSettingsSchema>; export const userRouter = createTRPCRouter({ getCurrentUser: publicProcedure.query(async ({ ctx }) => { - const strId = ctx?.session?.user?.id; - if (!strId) { + const email = ctx?.session?.user?.email; + + if (!email) { return NULL_RESULT_TRPC_INT; } const user = await ctx.db.user.findUnique({ where: { - id: parseInt(strId), + email, }, include: { subscriptions: true From 40cf590ca93cc105346ab00425218d827ee17084 Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Sun, 1 Dec 2024 16:03:09 -0500 Subject: [PATCH 29/31] feat: better adapter types --- .../src/server/LadderlyMigrationAdapter.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/ladderly-io/src/server/LadderlyMigrationAdapter.ts b/ladderly-io/src/server/LadderlyMigrationAdapter.ts index c1cabf2e..22e0f345 100644 --- a/ladderly-io/src/server/LadderlyMigrationAdapter.ts +++ b/ladderly-io/src/server/LadderlyMigrationAdapter.ts @@ -4,12 +4,14 @@ import { AdapterAccount, AdapterSession, AdapterUser, -} from "next-auth/adapters"; -import { ProviderType } from "next-auth/providers/index"; + VerificationToken, +} from "@auth/core/adapters"; + +import { AdapterAccountType } from "@auth/core/adapters"; export function LadderlyMigrationAdapter(prisma: PrismaClient): Adapter { return { - createUser: async (data) => { + createUser: async (data: AdapterUser) => { const prismaUserData: Prisma.UserCreateInput = { name: data.name ?? undefined, email: data.email, @@ -60,7 +62,7 @@ export function LadderlyMigrationAdapter(prisma: PrismaClient): Adapter { }); return adaptUser(user); }, - linkAccount: async (account) => { + linkAccount: async (account: AdapterAccount) => { const prismaAccountData: Prisma.AccountCreateInput = { provider: account.provider, type: account.type, @@ -70,7 +72,7 @@ export function LadderlyMigrationAdapter(prisma: PrismaClient): Adapter { token_type: account.token_type, scope: account.scope, id_token: account.id_token, - session_state: account.session_state, + session_state: account.session_state?.toString() ?? undefined, refresh_token: account.refresh_token, user: { connect: { id: parseInt(account.userId) }, @@ -90,6 +92,7 @@ export function LadderlyMigrationAdapter(prisma: PrismaClient): Adapter { return account ? adaptAccount(account) : undefined; }, createSession: async (data) => { + console.log("ENTERED CREATE SESSION WITH DATA:", data); const session = await prisma.session.create({ data: { ...data, @@ -105,6 +108,7 @@ export function LadderlyMigrationAdapter(prisma: PrismaClient): Adapter { where: { sessionToken }, include: { user: true }, }); + return result?.user ? { session: adaptSession(result), user: adaptUser(result.user) } : null; @@ -127,7 +131,7 @@ export function LadderlyMigrationAdapter(prisma: PrismaClient): Adapter { deleteSession: async (sessionToken) => { await prisma.session.delete({ where: { sessionToken } }); }, - createVerificationToken: (data) => + createVerificationToken: (data: VerificationToken) => prisma.verificationToken.create({ data }), useVerificationToken: (identifier_token) => prisma.verificationToken.delete({ where: { identifier_token } }), @@ -155,13 +159,13 @@ function adaptUser(user: User): AdapterUser { function adaptAccount(account: Account): AdapterAccount { return { userId: account.userId.toString(), - type: account.type as ProviderType, + type: account.type as AdapterAccountType, provider: account.provider, providerAccountId: account.providerAccountId, refresh_token: account.refresh_token ?? undefined, access_token: account.access_token ?? undefined, expires_at: account.expires_at ?? undefined, - token_type: account.token_type ?? undefined, + token_type: account.token_type as Lowercase<string> ?? undefined, scope: account.scope ?? undefined, id_token: account.id_token ?? undefined, session_state: account.session_state ?? undefined, From 3f3b682f681fbe5409c61867869b7e5b697cf9ba Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Sun, 1 Dec 2024 16:03:27 -0500 Subject: [PATCH 30/31] feat: simpler adapter --- .../src/server/LadderlyMigrationAdapter.ts | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/ladderly-io/src/server/LadderlyMigrationAdapter.ts b/ladderly-io/src/server/LadderlyMigrationAdapter.ts index 22e0f345..a44c663c 100644 --- a/ladderly-io/src/server/LadderlyMigrationAdapter.ts +++ b/ladderly-io/src/server/LadderlyMigrationAdapter.ts @@ -91,28 +91,6 @@ export function LadderlyMigrationAdapter(prisma: PrismaClient): Adapter { }); return account ? adaptAccount(account) : undefined; }, - createSession: async (data) => { - console.log("ENTERED CREATE SESSION WITH DATA:", data); - const session = await prisma.session.create({ - data: { - ...data, - handle: data.sessionToken, - expiresAt: data.expires, - userId: parseInt(data.userId), - }, - }); - return adaptSession(session); - }, - getSessionAndUser: async (sessionToken) => { - const result = await prisma.session.findUnique({ - where: { sessionToken }, - include: { user: true }, - }); - - return result?.user - ? { session: adaptSession(result), user: adaptUser(result.user) } - : null; - }, updateSession: async (data) => { const { sessionToken, ...updateData } = data; From bdc28ef31817f59682894e1c463c6bd8c19724db Mon Sep 17 00:00:00 2001 From: Vandivier <john@ladderly.io> Date: Thu, 5 Dec 2024 00:31:07 -0500 Subject: [PATCH 31/31] feat: refresh session after login --- .../src/app/(auth)/components/LoginForm.tsx | 2 +- .../components/page-wrapper/TopNavRight.tsx | 20 ++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/ladderly-io/src/app/(auth)/components/LoginForm.tsx b/ladderly-io/src/app/(auth)/components/LoginForm.tsx index 0681b39c..a5edf60d 100644 --- a/ladderly-io/src/app/(auth)/components/LoginForm.tsx +++ b/ladderly-io/src/app/(auth)/components/LoginForm.tsx @@ -23,7 +23,7 @@ export const LoginForm = () => { } if (result?.ok) { - router.push("/"); + router.push("/?refresh_current_user=true"); router.refresh(); } }; diff --git a/ladderly-io/src/app/core/components/page-wrapper/TopNavRight.tsx b/ladderly-io/src/app/core/components/page-wrapper/TopNavRight.tsx index 73560756..f128b34d 100644 --- a/ladderly-io/src/app/core/components/page-wrapper/TopNavRight.tsx +++ b/ladderly-io/src/app/core/components/page-wrapper/TopNavRight.tsx @@ -1,5 +1,7 @@ +import { useRouter } from "next/navigation"; +import { useSearchParams } from "next/navigation"; import Link from "next/link"; -import React from "react"; +import React, { useEffect } from "react"; import { api } from "~/trpc/react"; import { IconVerticalChevron } from "../icons/VerticalChevron"; import { MenuContext } from "./MenuProvider"; @@ -13,10 +15,26 @@ import { const TOP_NAV_RIGHT_SECTION_CLASSES = "ml-auto flex items-center space-x-6"; export const TopNavRight = () => { + const router = useRouter(); + const searchParams = useSearchParams(); const currentUserQuery = api.user.getCurrentUser.useQuery(); const currentUser = currentUserQuery.data; const { setMenu, openMenuName } = React.useContext(MenuContext); + useEffect(() => { + const refreshCurrentUser = searchParams.get("refresh_current_user"); + + if (refreshCurrentUser === "true") { + // Remove the query parameter and refetch data + const newQuery = new URLSearchParams(searchParams); + newQuery.delete("refresh_current_user"); + router.replace(`?${newQuery.toString()}`); + + // Refetch the current user + currentUserQuery.refetch(); + } + }, [searchParams, currentUserQuery, router]); + const handleCommunityClick = (e: React.MouseEvent<HTMLButtonElement>) => { e.preventDefault(); if (openMenuName === "community") {