From c88199b70e7b5dd734e462e8c9134648f479acd0 Mon Sep 17 00:00:00 2001 From: jsun969 Date: Sat, 17 Feb 2024 19:38:48 +1030 Subject: [PATCH 1/4] docs: add clerk doc and update readme --- README.md | 20 ++++++++++---------- docs/clerk.md | 11 +++++++++++ 2 files changed, 21 insertions(+), 10 deletions(-) create mode 100644 docs/clerk.md diff --git a/README.md b/README.md index 63f97939..d349b8cd 100644 --- a/README.md +++ b/README.md @@ -4,35 +4,35 @@ This is the official repository for the University of Adelaide Computer Science ## Getting Started -To get started, please follow these steps: +To get started, please follow these steps: + 1. Install the dependencies. + ```bash pnpm install ``` -2. Set up the required keys: -- Copy `.env.local.example` to a new file `.env.local`. -- Create a [Clerk](https://clerk.com) account and make a new application within the Clerk dashboard. -- Configure the settings to require a name, reject compromised passwords, and enforce average strength passwords. -- Copy the keys to `.env.local`. +2. Copy `.env.local.example` to a new file `.env.local` and set required environment variables (check `/docs` folder if you don't know how to edit it) 3. Initialise the database. + ```bash pnpm run db:push ``` -4. Then run the development server. +4. Run the development server. ```bash pnpm run dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +5. Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. ## Contributing We welcome contributions to enhance the CS Club Website! If you find any issues, have suggestions, or want to request a feature, please follow our [Contributing Guidelines](CONTRIBUTING.md). ## License -This project is licensed under the MIT License. -See [LICENSE](LICENSE) for details. \ No newline at end of file + +This project is licensed under the MIT License. +See [LICENSE](LICENSE) for details. diff --git a/docs/clerk.md b/docs/clerk.md new file mode 100644 index 00000000..0b5e608e --- /dev/null +++ b/docs/clerk.md @@ -0,0 +1,11 @@ +# Clerk + +## Getting started + +1. Create a [Clerk](https://clerk.com) account and make a new application within the Clerk dashboard. +2. Configure the settings to require a name, reject compromised passwords, and enforce average strength passwords. + +## Admin account + +1. Go to user profile in clerk dashboard +2. Set `{ "isAdmin": true }` in public metadata From 28fca250dec8d936dd4c83f3ac294a68dc5a6fb4 Mon Sep 17 00:00:00 2001 From: jsun969 Date: Sun, 18 Feb 2024 00:49:13 +1030 Subject: [PATCH 2/4] feat: add admin panel to change member payment --- package.json | 1 + pnpm-lock.yaml | 31 +++++++++++ src/app/(account)/settings/page.tsx | 9 +--- src/app/admin/MemberForm.tsx | 74 ++++++++++++++++++++++++++ src/app/admin/page.tsx | 46 +++++++++++++++++ src/app/api/payment/route.ts | 32 ++++++++++++ src/components/Autocomplete.tsx | 80 +++++++++++++++++++++++++++++ src/components/UserButton.tsx | 12 +++-- src/db/index.ts | 3 +- src/db/queries.ts | 10 ++++ src/db/schema.ts | 4 +- src/middleware.ts | 2 +- 12 files changed, 290 insertions(+), 14 deletions(-) create mode 100644 src/app/admin/MemberForm.tsx create mode 100644 src/app/admin/page.tsx create mode 100644 src/components/Autocomplete.tsx diff --git a/package.json b/package.json index 85a37bea..548b55ed 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dependencies": { "@clerk/clerk-react": "^4.30.3", "@clerk/nextjs": "^4.29.3", + "@headlessui/react": "^1.7.18", "@hookform/resolvers": "^3.3.4", "@libsql/client": "0.4.0-pre.7", "@t3-oss/env-nextjs": "^0.7.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2aacd516..aa25db9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ dependencies: '@clerk/nextjs': specifier: ^4.29.3 version: 4.29.3(next@14.0.4)(react-dom@18.2.0)(react@18.2.0) + '@headlessui/react': + specifier: ^1.7.18 + version: 1.7.18(react-dom@18.2.0)(react@18.2.0) '@hookform/resolvers': specifier: ^3.3.4 version: 3.3.4(react-hook-form@7.49.3) @@ -970,6 +973,19 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true + /@headlessui/react@1.7.18(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-4i5DOrzwN4qSgNsL4Si61VMkUcWbcSKueUV7sFhpHzQcSShdlHENE5+QBntMSRvHt8NyoFO2AGG8si9lq+w4zQ==} + engines: {node: '>=10'} + peerDependencies: + react: ^16 || ^17 || ^18 + react-dom: ^16 || ^17 || ^18 + dependencies: + '@tanstack/react-virtual': 3.0.4(react-dom@18.2.0)(react@18.2.0) + client-only: 0.0.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@hookform/resolvers@3.3.4(react-hook-form@7.49.3): resolution: {integrity: sha512-o5cgpGOuJYrd+iMKvkttOclgwRW86EsWJZZRC23prf0uU2i48Htq4PuT73AVb9ionFyZrwYEITuOFGF+BydEtQ==} peerDependencies: @@ -1431,6 +1447,21 @@ packages: zod: 3.22.4 dev: false + /@tanstack/react-virtual@3.0.4(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-tiqKW/e2MJVCr7/pRUXulpkyxllaOclkHNfhKTo4pmHjJIqnhMfwIjc1Q1R0Un3PI3kQywywu/791c8z9u0qeA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@tanstack/virtual-core': 3.0.0 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@tanstack/virtual-core@3.0.0: + resolution: {integrity: sha512-SYXOBTjJb05rXa2vl55TTwO40A6wKu0R5i1qQwhJYNDIqaIGF7D0HsLw+pJAyi2OvntlEIVusx3xtbbgSUi6zg==} + dev: false + /@trivago/prettier-plugin-sort-imports@4.3.0(prettier@3.2.1): resolution: {integrity: sha512-r3n0onD3BTOVUNPhR4lhVK4/pABGpbA7bW3eumZnYdKaHkf1qEC+Mag6DPbGNuuh0eG8AaYj+YqmVHSiGslaTQ==} peerDependencies: diff --git a/src/app/(account)/settings/page.tsx b/src/app/(account)/settings/page.tsx index f1bb3067..4008b05f 100644 --- a/src/app/(account)/settings/page.tsx +++ b/src/app/(account)/settings/page.tsx @@ -1,7 +1,7 @@ import FancyRectangle from '@/components/FancyRectangle'; import Title from '@/components/Title'; import { db } from '@/db'; -import { checkUserExists } from '@/db/queries'; +import { checkUserExists, updateMemberExpiryDate } from '@/db/queries'; import { memberTable } from '@/db/schema'; import { redisClient } from '@/lib/redis'; import { squareClient } from '@/lib/square'; @@ -39,12 +39,7 @@ const verifyMembershipPayment = async (clerkId: string) => { } // Set expiry date to be the January 1st of the following year - const now = new Date(); - const expiryDate = new Date(`${now.getFullYear() + 1}-01-01`); - await db - .update(memberTable) - .set({ membershipExpiresAt: expiryDate }) - .where(eq(memberTable.clerkId, clerkId)); + const expiryDate = updateMemberExpiryDate(clerkId, 'clerkId'); // Delete key from Redis since it is no longer needed await redisClient.del(`payment:membership:${clerkId}`); diff --git a/src/app/admin/MemberForm.tsx b/src/app/admin/MemberForm.tsx new file mode 100644 index 00000000..891c8539 --- /dev/null +++ b/src/app/admin/MemberForm.tsx @@ -0,0 +1,74 @@ +'use client'; + +import Autocomplete from '@/components/Autocomplete'; +import { fetcher } from '@/lib/fetcher'; +import { useState } from 'react'; +import useSWRMutation from 'swr/mutation'; +import type { Member } from './page'; + +const getMemberStr = (member: Member) => `${member.email} - ${member.firstName} ${member.lastName}`; + +function MemberDetail({ member }: { member: Member }) { + const { paid, ...details } = member; + + const [payment, setPayment] = useState(member.paid); + const updatePayment = useSWRMutation('payment', fetcher.put.mutate, { + onSuccess: () => { + setPayment(!payment); + }, + }); + const handlePaymentChange = () => { + updatePayment.trigger({ id: member.id, paid: !member.paid }); + }; + + return ( + + + {Object.entries(details).map(([key, value]) => ( + + + + + ))} + + + + + +
{key}{value}
Payment + +
+ ); +} + +export default function MemberForm({ members }: { members: Member[] }) { + const [selectedMember, setSelectedMember] = useState(null); + + return ( +
+ +
+ {selectedMember ? ( + + ) : ( +
+ Search a member to view details +
+ )} +
+
+ ); +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 00000000..234e69b3 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,46 @@ +import FancyRectangle from '@/components/FancyRectangle'; +import Title from '@/components/Title'; +import { db } from '@/db'; +import { currentUser } from '@clerk/nextjs'; +import { notFound } from 'next/navigation'; +import MemberForm from './MemberForm'; + +const queryMembers = async () => { + const dbMembers = await db.query.memberTable.findMany({ + columns: { + id: true, + firstName: true, + lastName: true, + email: true, + membershipExpiresAt: true, + createdAt: true, + }, + }); + const members = dbMembers.map(({ membershipExpiresAt, ...member }) => ({ + ...member, + paid: membershipExpiresAt !== null && membershipExpiresAt > new Date(), + })); + return members; +}; +export type Member = Awaited>[number]; + +export default async function AdminPage() { + const user = await currentUser(); + if (!user?.publicMetadata.isAdmin) { + return notFound(); + } + const members = await queryMembers(); + + return ( +
+
+ Admin Panel +
+ +
+ +
+
+
+ ); +} diff --git a/src/app/api/payment/route.ts b/src/app/api/payment/route.ts index f5b504d9..f27a562e 100644 --- a/src/app/api/payment/route.ts +++ b/src/app/api/payment/route.ts @@ -5,10 +5,14 @@ * verify that the user is signed in (see `src/middleware.ts`) */ import { PRODUCTS } from '@/data/products'; +import { db } from '@/db'; +import { updateMemberExpiryDate } from '@/db/queries'; +import { memberTable } from '@/db/schema'; import { env } from '@/env.mjs'; import { redisClient } from '@/lib/redis'; import { squareClient } from '@/lib/square'; import { currentUser } from '@clerk/nextjs'; +import { eq } from 'drizzle-orm'; import type { CreatePaymentLinkRequest } from 'square'; import { ApiError } from 'square'; import { z } from 'zod'; @@ -84,3 +88,31 @@ export async function POST(request: Request) { return new Response(null, { status: 500 }); } } + +export async function PUT(request: Request) { + const req = await request.json(); + const schema = z.object({ + id: z.string().min(1), + paid: z.boolean(), + }); + + const user = await currentUser(); + if (!user?.publicMetadata.isAdmin) { + return new Response(null, { status: 401 }); + } + + const reqBody = schema.safeParse(req); + if (!reqBody.success) { + return new Response(JSON.stringify(reqBody.error.format()), { status: 400 }); + } + + if (reqBody.data.paid) { + await updateMemberExpiryDate(reqBody.data.id, 'id'); + } else { + await db + .update(memberTable) + .set({ membershipExpiresAt: null }) + .where(eq(memberTable.id, reqBody.data.id)); + } + return Response.json({ success: true }); +} diff --git a/src/components/Autocomplete.tsx b/src/components/Autocomplete.tsx new file mode 100644 index 00000000..2e885bc6 --- /dev/null +++ b/src/components/Autocomplete.tsx @@ -0,0 +1,80 @@ +import { Combobox } from '@headlessui/react'; +import { useState } from 'react'; +import { IoCaretDown, IoCheckmark } from 'react-icons/io5'; + +interface AutocompleteProps { + value: TOption | null; + onChange: (option: TOption) => void; + options: TOption[]; + displayOptionStr: (option: TOption) => string; + placeholder?: string; + notFoundMessage?: string; + className?: string; +} +export default function Autocomplete({ + value, + onChange, + options, + displayOptionStr, + placeholder, + notFoundMessage, + className, +}: AutocompleteProps) { + const [query, setQuery] = useState(''); + const filteredOptions = + query === '' + ? options + : options.filter((option) => + displayOptionStr(option).toLowerCase().includes(query.toLowerCase()) + ); + + return ( + +
+
+ setQuery(e.target.value)} + className="w-full border border-gray-300 px-3 py-2 text-grey" + displayValue={(option: TOption | null) => + option ? displayOptionStr(option) : '' + } + placeholder={placeholder} + /> + + + +
+ + {filteredOptions.length === 0 && query !== '' ? ( +
+ {notFoundMessage ?? 'No results found'} +
+ ) : ( + filteredOptions.map((option, i) => ( + + `relative cursor-default select-none py-2 pl-10 pr-4 ${ + active ? 'bg-grey text-white' : 'text-black' + }` + } + > + {({ selected }) => ( + <> + {selected && ( + + + + )} + {displayOptionStr(option)} + + )} + + )) + )} +
+
+
+ ); +} diff --git a/src/components/UserButton.tsx b/src/components/UserButton.tsx index 0614d1fc..67c40ed7 100644 --- a/src/components/UserButton.tsx +++ b/src/components/UserButton.tsx @@ -18,14 +18,13 @@ export default function UserButton({ userExists }: { userExists: boolean }) { await signOut(); }; + if (!user) return <>; + return (
{/* Popup menu */} @@ -46,6 +45,11 @@ export default function UserButton({ userExists }: { userExists: boolean }) { Settings + {user.publicMetadata.isAdmin && ( + + Admin Panel + + )} )} {/* Sign Out */} diff --git a/src/db/index.ts b/src/db/index.ts index ce95ec6b..4046303a 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,10 +1,11 @@ import { env } from '@/env.mjs'; import { createClient } from '@libsql/client'; import { drizzle } from 'drizzle-orm/libsql'; +import * as schema from './schema'; const client = createClient({ url: env.DATABASE_URL, authToken: env.DATABASE_AUTH_TOKEN, }); -export const db = drizzle(client); +export const db = drizzle(client, { schema }); diff --git a/src/db/queries.ts b/src/db/queries.ts index ffe1706d..86f822eb 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -9,3 +9,13 @@ export const checkUserExists = async (clerkUserId: string) => { .where(eq(memberTable.clerkId, clerkUserId)); return existingUser.length > 0; }; + +export const updateMemberExpiryDate = async (id: string, idType: 'clerkId' | 'id') => { + const now = new Date(); + const expiryDate = new Date(`${now.getFullYear() + 1}-01-01`); + await db + .update(memberTable) + .set({ membershipExpiresAt: expiryDate }) + .where(eq(memberTable[idType], id)); + return expiryDate; +}; diff --git a/src/db/schema.ts b/src/db/schema.ts index 55f32ead..de395fa4 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -30,7 +30,9 @@ export const memberTable = sqliteTable('members', { membershipExpiresAt: integer('membership_expires_at', { mode: 'timestamp' }), - createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), + createdAt: text('created_at') + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), // TODO: `updated_at` in sqlite // updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`), }); diff --git a/src/middleware.ts b/src/middleware.ts index d5146449..9d9bdc5d 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,6 +1,6 @@ import { authMiddleware } from '@clerk/nextjs'; -const authRoutes = ['/account', '/dashboard', '/settings']; +const authRoutes = ['/account', '/dashboard', '/settings', '/admin']; export default authMiddleware({ publicRoutes: (req) => { From e89544b3e64438091b834d7aa4c5a27d95028764 Mon Sep 17 00:00:00 2001 From: jsun969 Date: Sun, 18 Feb 2024 01:50:17 +1030 Subject: [PATCH 3/4] chore(api): add comment for payment api --- src/app/api/payment/route.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/api/payment/route.ts b/src/app/api/payment/route.ts index f27a562e..ceaddcc9 100644 --- a/src/app/api/payment/route.ts +++ b/src/app/api/payment/route.ts @@ -89,6 +89,7 @@ export async function POST(request: Request) { } } +// Update member's payment status for admin export async function PUT(request: Request) { const req = await request.json(); const schema = z.object({ From 4bf22b0bcd7241f4fb640a82027e4f56d73d25da Mon Sep 17 00:00:00 2001 From: Ray Okamoto <22254748+rayokamoto@users.noreply.github.com> Date: Sun, 18 Feb 2024 01:58:09 +1030 Subject: [PATCH 4/4] chore: Update comment on PUT request function --- src/app/api/payment/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/payment/route.ts b/src/app/api/payment/route.ts index ceaddcc9..e231873e 100644 --- a/src/app/api/payment/route.ts +++ b/src/app/api/payment/route.ts @@ -89,7 +89,7 @@ export async function POST(request: Request) { } } -// Update member's payment status for admin +// Update member's payment status via admin console export async function PUT(request: Request) { const req = await request.json(); const schema = z.object({