Skip to content

Commit ee51421

Browse files
authored
Merge pull request #36 from GTBitsOfGood/nathan/sprint2
Users table + add names to users
2 parents fcaaf8a + 806db2e commit ee51421

File tree

4 files changed

+210
-54
lines changed

4 files changed

+210
-54
lines changed

src/app/api/users/route.ts

+60-52
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
11
import { auth } from "@/auth";
22
import { db } from "@/db";
3-
import { argumentError, conflictError, notFoundError, authenticationError, authorizationError, ok } from "@/util/responses";
3+
import {
4+
argumentError,
5+
conflictError,
6+
notFoundError,
7+
authenticationError,
8+
authorizationError,
9+
ok,
10+
} from "@/util/responses";
411
import { UserType } from "@prisma/client";
512
import { NextRequest, NextResponse } from "next/server";
613
import { zfd } from "zod-form-data";
7-
import * as argon2 from 'argon2';
14+
import * as argon2 from "argon2";
815
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
916

1017
const ALLOWED_USER_TYPES: UserType[] = [
11-
UserType.ADMIN,
12-
UserType.STAFF,
13-
UserType.SUPER_ADMIN,
18+
UserType.ADMIN,
19+
UserType.STAFF,
20+
UserType.SUPER_ADMIN,
1421
];
1522

1623
/**
@@ -22,27 +29,28 @@ const ALLOWED_USER_TYPES: UserType[] = [
2229
* @returns 200 with a list of users, including their email and role type
2330
*/
2431
export async function GET() {
25-
const session = await auth();
26-
if (!session?.user) return authenticationError("Session required");
32+
const session = await auth();
33+
if (!session?.user) return authenticationError("Session required");
2734

28-
const { user } = session;
29-
if (!ALLOWED_USER_TYPES.includes(user.type)) {
30-
return authorizationError("Must be STAFF, ADMIN, or SUPER_ADMIN");
31-
}
35+
const { user } = session;
36+
if (!ALLOWED_USER_TYPES.includes(user.type)) {
37+
return authorizationError("Must be STAFF, ADMIN, or SUPER_ADMIN");
38+
}
3239

33-
const users = await db.user.findMany({
34-
select: {
35-
email: true,
36-
type: true,
37-
},
38-
});
40+
const users = await db.user.findMany({
41+
select: {
42+
email: true,
43+
type: true,
44+
name: true,
45+
},
46+
});
3947

40-
return NextResponse.json(users, { status: 200 });
48+
return NextResponse.json(users, { status: 200 });
4149
}
4250

4351
const schema = zfd.formData({
44-
inviteToken: zfd.text(),
45-
password: zfd.text()
52+
inviteToken: zfd.text(),
53+
password: zfd.text(),
4654
});
4755

4856
/**
@@ -56,38 +64,38 @@ const schema = zfd.formData({
5664
* @returns 200
5765
*/
5866
export async function POST(req: NextRequest) {
59-
const parsed = schema.safeParse(await req.formData());
60-
if (!parsed.success) {
61-
return argumentError("Invalid user data");
62-
}
63-
64-
const { inviteToken, password } = parsed.data;
65-
const userInvite = await db.userInvite.findUnique({
66-
where: {
67-
token: inviteToken
68-
}
67+
const parsed = schema.safeParse(await req.formData());
68+
if (!parsed.success) {
69+
return argumentError("Invalid user data");
70+
}
71+
72+
const { inviteToken, password } = parsed.data;
73+
const userInvite = await db.userInvite.findUnique({
74+
where: {
75+
token: inviteToken,
76+
},
77+
});
78+
if (!userInvite) {
79+
return notFoundError("Invite does not exist");
80+
} else if (userInvite.expiration < new Date()) {
81+
return argumentError("Invite has expired");
82+
}
83+
try {
84+
await db.user.create({
85+
data: {
86+
name: userInvite.name,
87+
email: userInvite.email,
88+
passwordHash: await argon2.hash(password),
89+
type: userInvite.userType,
90+
},
6991
});
70-
if (!userInvite) {
71-
return notFoundError("Invite does not exist");
72-
} else if (userInvite.expiration < new Date()) {
73-
return argumentError("Invite has expired");
74-
}
75-
try {
76-
await db.user.create({
77-
data: {
78-
name: userInvite.name,
79-
email: userInvite.email,
80-
passwordHash: await argon2.hash(password),
81-
type: userInvite.userType
82-
}
83-
});
84-
} catch (e) {
85-
if (e instanceof PrismaClientKnownRequestError) {
86-
if (e.code === 'P2002') {
87-
return conflictError("User already exists");
88-
}
89-
}
90-
throw e;
92+
} catch (e) {
93+
if (e instanceof PrismaClientKnownRequestError) {
94+
if (e.code === "P2002") {
95+
return conflictError("User already exists");
96+
}
9197
}
92-
return ok();
98+
throw e;
99+
}
100+
return ok();
93101
}

src/components/NavBarLayout.tsx

+4-2
Original file line numberDiff line numberDiff line change
@@ -178,13 +178,15 @@ export default function NavbarLayout({
178178

179179
return (
180180
<div className="flex">
181-
{user && (
181+
{user ? (
182182
<>
183183
<DesktopNavbar />
184184
<MobileNavbar />
185+
<main className="flex-1 px-6 py-8 overflow-scroll">{children}</main>
185186
</>
187+
) : (
188+
<>{children}</>
186189
)}
187-
<main className="flex-1">{children}</main>
188190
</div>
189191
);
190192
}
+140
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,147 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import { DotsThree, MagnifyingGlass, Plus } from "@phosphor-icons/react";
5+
import { CgSpinner } from "react-icons/cg";
6+
import { User, UserType } from "@prisma/client";
7+
8+
enum UserFilterKey {
9+
ALL = "All",
10+
STAFF = "Hope for Haiti Staff",
11+
PARTNERS = "Partners",
12+
}
13+
14+
const filterMap: Record<UserFilterKey, (user: User) => boolean> = {
15+
[UserFilterKey.ALL]: () => true,
16+
[UserFilterKey.STAFF]: (user) =>
17+
user.type === UserType.STAFF ||
18+
user.type === UserType.ADMIN ||
19+
user.type === UserType.SUPER_ADMIN,
20+
[UserFilterKey.PARTNERS]: (user) => user.type === UserType.PARTNER,
21+
};
22+
23+
function formatUserType(type: UserType): string {
24+
return type
25+
.toLowerCase()
26+
.split("_")
27+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
28+
.join(" ");
29+
}
30+
131
export default function AccountManagementScreen() {
32+
const [users, setUsers] = useState<User[]>([]);
33+
const [filteredUsers, setFilteredUsers] = useState<User[]>([]);
34+
const [activeTab, setActiveTab] = useState<string>("All");
35+
const [isLoading, setIsLoading] = useState(true);
36+
37+
useEffect(() => {
38+
const fetchUsers = async () => {
39+
try {
40+
const response = await fetch("/api/users");
41+
if (response.ok) {
42+
const data = await response.json();
43+
setUsers(data);
44+
setFilteredUsers(data);
45+
}
46+
} catch (error) {
47+
console.error("Failed to fetch users:", error);
48+
} finally {
49+
setIsLoading(false);
50+
}
51+
};
52+
53+
fetchUsers();
54+
}, []);
55+
56+
const filterUsers = (type: UserFilterKey) => {
57+
setActiveTab(type);
58+
setFilteredUsers(users.filter(filterMap[type]));
59+
};
60+
261
return (
362
<>
463
<h1 className="text-2xl font-semibold">Account Management</h1>
64+
<div className="flex justify-between items-center w-full py-4">
65+
<div className="relative w-1/3">
66+
<MagnifyingGlass
67+
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"
68+
size={18}
69+
/>
70+
<input
71+
type="text"
72+
placeholder="Search"
73+
className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-lg bg-gray-100 focus:outline-none focus:border-gray-400"
74+
/>
75+
</div>
76+
<button className="flex items-center gap-2 bg-red-500 text-white px-4 py-2 rounded-lg font-medium hover:bg-red-600 transition">
77+
<Plus size={18} /> Add account
78+
</button>
79+
</div>
80+
<div className="flex space-x-4 mt-4 border-b-2">
81+
{Object.keys(filterMap).map((tab) => {
82+
const key = tab as UserFilterKey;
83+
84+
return (
85+
<button
86+
key={tab}
87+
data-active={activeTab === tab}
88+
className="px-2 py-1 text-md font-medium relative -mb-px transition-colors focus:outline-none data-[active=true]:border-b-2 data-[active=true]:border-black data-[active=true]:bottom-[-1px] data-[active=false]:text-gray-500"
89+
onClick={() => filterUsers(key)}
90+
>
91+
<div className="hover:bg-gray-100 px-2 py-1 rounded">{tab}</div>
92+
</button>
93+
);
94+
})}
95+
</div>
96+
97+
{isLoading ? (
98+
<div className="flex justify-center items-center mt-8">
99+
<CgSpinner className="w-16 h-16 animate-spin opacity-50" />
100+
</div>
101+
) : (
102+
<div className="overflow-x-scroll">
103+
<table className="mt-4 rounded-t-lg overflow-hidden">
104+
<thead>
105+
<tr className="bg-gray-100 border-b-2">
106+
<th className="px-4 py-2 text-left font-normal">Name</th>
107+
<th className="px-4 py-2 text-left font-normal">Email</th>
108+
<th className="px-4 py-2 text-left font-normal">Role</th>
109+
<th className="px-4 py-2 text-left font-normal">Status</th>
110+
<th className="px-4 py-2 text-left font-normal">Manage</th>
111+
</tr>
112+
</thead>
113+
<tbody>
114+
{filteredUsers.map((user, index) => (
115+
<tr
116+
key={index}
117+
data-odd={index % 2 !== 0}
118+
className="bg-white data-[odd=true]:bg-gray-50"
119+
>
120+
<td className="border-b px-4 py-2 w-1/5">{user.name}</td>
121+
<td className="border-b px-4 py-2 w-1/5">{user.email}</td>
122+
<td className="border-b px-4 py-2 w-1/5">
123+
{formatUserType(user.type)}
124+
</td>
125+
<td className="border-b px-4 py-2 w-1/5">
126+
<span className="px-2 py-1 rounded bg-green-primary whitespace-nowrap">
127+
Account created
128+
</span>
129+
</td>
130+
<td className="border-b px-4 py-2 w-12">
131+
<div className="float-right">
132+
<DotsThree
133+
weight="bold"
134+
className="cursor-pointer"
135+
onClick={() => {}}
136+
/>
137+
</div>
138+
</td>
139+
</tr>
140+
))}
141+
</tbody>
142+
</table>
143+
</div>
144+
)}
5145
</>
6146
);
7147
}

tailwind.config.ts

+6
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ export default {
1313
light: "#f6f7ff",
1414
dark: "#ced8fa",
1515
},
16+
green: {
17+
primary: "#b7e394",
18+
},
19+
yellow: {
20+
primary: "#ffeeB0",
21+
},
1622
},
1723
},
1824
},

0 commit comments

Comments
 (0)