Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add admin panel #59

Merged
merged 4 commits into from
Feb 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

This project is licensed under the MIT License.
See [LICENSE](LICENSE) for details.
11 changes: 11 additions & 0 deletions docs/clerk.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
31 changes: 31 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 2 additions & 7 deletions src/app/(account)/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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}`);
Expand Down
74 changes: 74 additions & 0 deletions src/app/admin/MemberForm.tsx
Original file line number Diff line number Diff line change
@@ -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';
rayokamoto marked this conversation as resolved.
Show resolved Hide resolved

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 (
<table className="relative [&>tbody>*>*]:border [&>tbody>*>*]:border-grey [&>tbody>*>*]:px-4 [&>tbody>*>*]:py-2">
<tbody>
{Object.entries(details).map(([key, value]) => (
<tr key={key}>
<td className="font-bold capitalize">{key}</td>
<td>{value}</td>
</tr>
))}
<tr>
<td className="font-bold">Payment</td>
<td>
<input
type="checkbox"
checked={payment}
onChange={handlePaymentChange}
disabled={updatePayment.isMutating}
/>
</td>
</tr>
</tbody>
</table>
);
}

export default function MemberForm({ members }: { members: Member[] }) {
const [selectedMember, setSelectedMember] = useState<Member | null>(null);

return (
<div className="w-full space-y-4">
<Autocomplete
options={members}
value={selectedMember}
onChange={setSelectedMember}
displayOptionStr={getMemberStr}
placeholder="Search for a member by email or name..."
notFoundMessage="No members found"
className="grow"
/>
<div className="flex justify-center">
{selectedMember ? (
<MemberDetail member={selectedMember} />
) : (
<div className="py-24 text-center text-4xl">
Search a member to view details
</div>
)}
</div>
</div>
);
}
46 changes: 46 additions & 0 deletions src/app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof queryMembers>>[number];

export default async function AdminPage() {
const user = await currentUser();
if (!user?.publicMetadata.isAdmin) {
return notFound();
}
const members = await queryMembers();

return (
<div className="space-y-8">
<div className="flex justify-center">
<Title colour="purple">Admin Panel</Title>
</div>
<FancyRectangle colour="purple" offset="8" filled fullWidth>
<div className="w-full border-4 border-black bg-white px-8 py-8 text-black md:px-12 md:py-12">
<MemberForm members={members} />
</div>
</FancyRectangle>
</div>
);
}
33 changes: 33 additions & 0 deletions src/app/api/payment/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -84,3 +88,32 @@ export async function POST(request: Request) {
return new Response(null, { status: 500 });
}
}

rayokamoto marked this conversation as resolved.
Show resolved Hide resolved
// Update member's payment status via admin console
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 });
}
80 changes: 80 additions & 0 deletions src/components/Autocomplete.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { Combobox } from '@headlessui/react';
import { useState } from 'react';
import { IoCaretDown, IoCheckmark } from 'react-icons/io5';

interface AutocompleteProps<TOption> {
value: TOption | null;
onChange: (option: TOption) => void;
options: TOption[];
displayOptionStr: (option: TOption) => string;
placeholder?: string;
notFoundMessage?: string;
className?: string;
}
export default function Autocomplete<TOption>({
value,
onChange,
options,
displayOptionStr,
placeholder,
notFoundMessage,
className,
}: AutocompleteProps<TOption>) {
const [query, setQuery] = useState('');
const filteredOptions =
query === ''
? options
: options.filter((option) =>
displayOptionStr(option).toLowerCase().includes(query.toLowerCase())
);

return (
<Combobox value={value} onChange={onChange}>
<div className={`${className} relative w-full`}>
<div className="relative w-full">
<Combobox.Input
onChange={(e) => 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}
/>
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-2">
<IoCaretDown className="h-5 w-5 fill-grey" />
</Combobox.Button>
</div>
<Combobox.Options className="absolute z-50 mt-1 max-h-60 w-full overflow-auto bg-white py-1 text-base shadow-lg ring-1 ring-black/5 focus:outline-none">
{filteredOptions.length === 0 && query !== '' ? (
<div className="relative select-none px-4 py-2 text-grey">
{notFoundMessage ?? 'No results found'}
</div>
) : (
filteredOptions.map((option, i) => (
<Combobox.Option
key={i}
value={option}
className={({ active }) =>
`relative cursor-default select-none py-2 pl-10 pr-4 ${
active ? 'bg-grey text-white' : 'text-black'
}`
}
>
{({ selected }) => (
<>
{selected && (
<span className="absolute inset-y-0 left-0 flex items-center pl-3">
<IoCheckmark className="h-5 w-5" />
</span>
)}
<span>{displayOptionStr(option)}</span>
</>
)}
</Combobox.Option>
))
)}
</Combobox.Options>
</div>
</Combobox>
);
}
Loading
Loading