Skip to content

Commit d5880b2

Browse files
authored
feat: Improve approval group management (#16)
- Closes #9 - Closes #10 - Closes #15
1 parent b656baf commit d5880b2

File tree

12 files changed

+1736
-898
lines changed

12 files changed

+1736
-898
lines changed

apps/webapp/package.json

+11-10
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,10 @@
5454
"embla-carousel-react": "^8.5.1",
5555
"eslint-config-custom": "workspace:^",
5656
"firebase": "^11.1.0",
57+
"firebase-admin": "^13.0.2",
5758
"input-otp": "^1.4.1",
5859
"lucide-react": "^0.469.0",
59-
"next": "15.1.1-canary.19",
60+
"next": "15.1.2",
6061
"next-auth": "^4.24.11",
6162
"next-themes": "^0.4.4",
6263
"pg": "^8.13.1",
@@ -73,17 +74,17 @@
7374
"zod": "^3.24.1"
7475
},
7576
"devDependencies": {
76-
"@eslint/eslintrc": "^3",
77-
"@types/node": "^20",
77+
"@eslint/eslintrc": "^3.2.0",
78+
"@types/node": "^22.10.2",
7879
"@types/pg": "^8.11.10",
79-
"@types/react": "^19",
80-
"@types/react-dom": "^19",
80+
"@types/react": "^19.0.2",
81+
"@types/react-dom": "^19.0.2",
8182
"dotenv": "^16.4.7",
8283
"drizzle-kit": "^0.30.1",
83-
"eslint": "^9",
84-
"eslint-config-next": "15.1.1-canary.19",
85-
"postcss": "^8",
86-
"tailwindcss": "^3.4.1",
87-
"typescript": "^5"
84+
"eslint": "^9.17.0",
85+
"eslint-config-next": "15.1.2",
86+
"postcss": "^8.4.49",
87+
"tailwindcss": "^3.4.17",
88+
"typescript": "^5.7.2"
8889
}
8990
}

apps/webapp/src/app/app/devices/page.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export default function Devices() {
1313
const utils = trpc.useUtils();
1414
const unregisterMutation = trpc.devices.unregisterDevice.useMutation({
1515
onSuccess: () => {
16-
utils.devices.getDevices.invalidate();
16+
void utils.devices.getDevices.invalidate();
1717
},
1818
});
1919

apps/webapp/src/app/app/devices/register-device-dialog.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export function RegisterDeviceDialog({ onAdd }: { onAdd: () => void }) {
2727
const registerDevice = trpc.devices.registerDevice.useMutation({
2828
onSuccess: (device) => {
2929
setRegisteredDeviceId(device.id);
30-
handleWebAuthnRegistration(device.id);
30+
void handleWebAuthnRegistration(device.id);
3131
},
3232
});
3333

@@ -120,7 +120,7 @@ export function RegisterDeviceDialog({ onAdd }: { onAdd: () => void }) {
120120
<div className="text-center">
121121
<p className="text-sm text-gray-600 mb-2">
122122
Please complete the WebAuthn registration by following your
123-
browser's prompts.
123+
browser&apos;s prompts.
124124
</p>
125125
{generateWebAuthnOptions.isPending && (
126126
<p>Preparing WebAuthn registration...</p>

apps/webapp/src/app/app/packages/[packageId]/config/page.tsx

+191-22
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,26 @@ type Props = {
99
}>;
1010
};
1111

12+
type ApprovalGroup = {
13+
id: number;
14+
name: string;
15+
};
16+
17+
type GroupMember = {
18+
userId: string;
19+
name: string | null;
20+
};
21+
22+
type PackageMember = {
23+
userId: string;
24+
name: string | null;
25+
email: string | null;
26+
};
27+
1228
export default function PackageApprovalConfig(props: Props) {
1329
const params = use(props.params);
1430
const [newGroupName, setNewGroupName] = useState("");
31+
const [selectedGroupId, setSelectedGroupId] = useState<number | null>(null);
1532
const { data: packageDetails } = trpc.packages.getPackageDetails.useQuery({
1633
packageId: parseInt(params.packageId),
1734
});
@@ -22,7 +39,18 @@ export default function PackageApprovalConfig(props: Props) {
2239
} = trpc.approvals.getPackageApprovalGroups.useQuery({
2340
packageId: parseInt(params.packageId),
2441
});
42+
const { data: packageMembers } = trpc.packages.getPackageMembers.useQuery({
43+
packageId: parseInt(params.packageId),
44+
});
45+
const { data: groupMembers, refetch: refetchGroupMembers } =
46+
trpc.approvals.getGroupMembers.useQuery(
47+
{ groupId: selectedGroupId! },
48+
{ enabled: !!selectedGroupId },
49+
);
2550
const createGroupMutation = trpc.approvals.createApprovalGroup.useMutation();
51+
const addUserMutation = trpc.approvals.addUserToApprovalGroup.useMutation();
52+
const removeUserMutation =
53+
trpc.approvals.removeUserFromApprovalGroup.useMutation();
2654

2755
const handleCreateGroup = async (e: React.FormEvent) => {
2856
e.preventDefault();
@@ -41,6 +69,36 @@ export default function PackageApprovalConfig(props: Props) {
4169
}
4270
};
4371

72+
const handleAddUser = async (userId: string) => {
73+
if (!selectedGroupId) return;
74+
try {
75+
await addUserMutation.mutateAsync({
76+
groupId: selectedGroupId,
77+
userId,
78+
});
79+
void refetch();
80+
} catch (error) {
81+
console.error("Error adding user:", error);
82+
alert("Failed to add user. A user can only be in one approval group.");
83+
}
84+
void refetchGroupMembers();
85+
};
86+
87+
const handleRemoveUser = async (userId: string) => {
88+
if (!selectedGroupId) return;
89+
try {
90+
await removeUserMutation.mutateAsync({
91+
groupId: selectedGroupId,
92+
userId,
93+
});
94+
void refetch();
95+
} catch (error) {
96+
console.error("Error removing user:", error);
97+
alert("Failed to remove user. Please try again.");
98+
}
99+
void refetchGroupMembers();
100+
};
101+
44102
if (isLoading) return <div>Loading...</div>;
45103

46104
if (!packageDetails?.isOwner) {
@@ -55,33 +113,144 @@ export default function PackageApprovalConfig(props: Props) {
55113
);
56114
}
57115

116+
const availableMembers = packageMembers?.filter(
117+
(member: PackageMember) =>
118+
!groupMembers?.some((gm: GroupMember) => gm.userId === member.userId),
119+
);
120+
58121
return (
59122
<div className="space-y-6">
60123
<h1 className="text-3xl font-bold">Package Approval Configuration</h1>
61124
<div className="bg-white rounded-lg shadow-md p-6">
62125
<h2 className="text-xl font-semibold mb-4">Approval Groups</h2>
63-
<ul className="space-y-2 mb-4">
64-
{approvalGroups?.map((group) => (
65-
<li key={group.id} className="bg-gray-50 p-3 rounded-md">
66-
{group.name}
67-
</li>
68-
))}
69-
</ul>
70-
<form onSubmit={handleCreateGroup} className="flex space-x-2">
71-
<input
72-
type="text"
73-
value={newGroupName}
74-
onChange={(e) => setNewGroupName(e.target.value)}
75-
placeholder="New group name"
76-
className="flex-grow px-3 py-2 border border-gray-300 rounded-md"
77-
/>
78-
<button
79-
type="submit"
80-
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
81-
>
82-
Create Group
83-
</button>
84-
</form>
126+
<div className="grid grid-cols-2 gap-6">
127+
<div>
128+
<ul className="space-y-2 mb-4">
129+
{approvalGroups?.map((group: ApprovalGroup) => (
130+
<li
131+
key={group.id}
132+
className={`bg-gray-50 p-3 rounded-md cursor-pointer flex items-center justify-between hover:bg-gray-100 transition-colors duration-150 ${
133+
selectedGroupId === group.id ? "ring-2 ring-blue-500" : ""
134+
}`}
135+
onClick={() => setSelectedGroupId(group.id)}
136+
>
137+
<span>{group.name}</span>
138+
<svg
139+
xmlns="http://www.w3.org/2000/svg"
140+
className="h-5 w-5 text-gray-400"
141+
viewBox="0 0 20 20"
142+
fill="currentColor"
143+
>
144+
<path
145+
fillRule="evenodd"
146+
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
147+
clipRule="evenodd"
148+
/>
149+
</svg>
150+
</li>
151+
))}
152+
</ul>
153+
<form onSubmit={handleCreateGroup} className="flex space-x-2">
154+
<input
155+
type="text"
156+
value={newGroupName}
157+
onChange={(e) => setNewGroupName(e.target.value)}
158+
placeholder="New group name"
159+
className="flex-grow px-3 py-2 border border-gray-300 rounded-md"
160+
/>
161+
<button
162+
type="submit"
163+
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded inline-flex items-center space-x-1"
164+
>
165+
<svg
166+
xmlns="http://www.w3.org/2000/svg"
167+
className="h-5 w-5"
168+
viewBox="0 0 20 20"
169+
fill="currentColor"
170+
>
171+
<path
172+
fillRule="evenodd"
173+
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
174+
clipRule="evenodd"
175+
/>
176+
</svg>
177+
<span>Add</span>
178+
</button>
179+
</form>
180+
</div>
181+
182+
{selectedGroupId && (
183+
<div>
184+
<h3 className="text-lg font-semibold mb-3">Group Members</h3>
185+
{groupMembers && groupMembers.length > 0 ? (
186+
<ul className="space-y-2 mb-4">
187+
{groupMembers.map((member: GroupMember) => (
188+
<li
189+
key={member.userId}
190+
className="flex justify-between items-center bg-gray-50 p-3 rounded-md hover:bg-gray-100 transition-colors duration-150"
191+
>
192+
<span>{member.name || member.userId}</span>
193+
<button
194+
onClick={() => handleRemoveUser(member.userId)}
195+
className="text-red-500 hover:text-red-700 inline-flex items-center space-x-1"
196+
>
197+
<svg
198+
xmlns="http://www.w3.org/2000/svg"
199+
className="h-5 w-5"
200+
viewBox="0 0 20 20"
201+
fill="currentColor"
202+
>
203+
<path
204+
fillRule="evenodd"
205+
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
206+
clipRule="evenodd"
207+
/>
208+
</svg>
209+
<span>Remove</span>
210+
</button>
211+
</li>
212+
))}
213+
</ul>
214+
) : (
215+
<p className="text-gray-500 mb-4">No members in this group</p>
216+
)}
217+
218+
<h3 className="text-lg font-semibold mb-3">Available Members</h3>
219+
{availableMembers && availableMembers.length > 0 ? (
220+
<ul className="space-y-2">
221+
{availableMembers.map((member: PackageMember) => (
222+
<li
223+
key={member.userId}
224+
className="flex justify-between items-center bg-gray-50 p-3 rounded-md hover:bg-gray-100 transition-colors duration-150"
225+
>
226+
<span>{member.name || member.userId}</span>
227+
<button
228+
onClick={() => handleAddUser(member.userId)}
229+
className="text-blue-500 hover:text-blue-700 inline-flex items-center space-x-1"
230+
>
231+
<svg
232+
xmlns="http://www.w3.org/2000/svg"
233+
className="h-5 w-5"
234+
viewBox="0 0 20 20"
235+
fill="currentColor"
236+
>
237+
<path
238+
fillRule="evenodd"
239+
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
240+
clipRule="evenodd"
241+
/>
242+
</svg>
243+
<span>Add</span>
244+
</button>
245+
</li>
246+
))}
247+
</ul>
248+
) : (
249+
<p className="text-gray-500">No available members</p>
250+
)}
251+
</div>
252+
)}
253+
</div>
85254
</div>
86255
</div>
87256
);

apps/webapp/src/app/app/packages/[packageId]/members/page.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -70,16 +70,16 @@ export default function PackageMembers(props: Props) {
7070
<ul className="space-y-2 mb-4">
7171
{members?.map((member) => (
7272
<li
73-
key={member.id}
73+
key={member.userId}
7474
className="flex justify-between items-center bg-gray-50 p-3 rounded-md"
7575
>
7676
<span>
7777
{member.name} ({member.email})
7878
</span>
7979
{packageDetails.isOwner &&
80-
packageDetails.ownerId !== member.id && (
80+
packageDetails.ownerId !== member.userId && (
8181
<button
82-
onClick={() => handleRemoveMember(member.id)}
82+
onClick={() => handleRemoveMember(member.userId)}
8383
className="text-red-500 hover:text-red-700"
8484
>
8585
Remove

apps/webapp/src/app/app/packages/[packageId]/page.tsx

+19
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import Link from "next/link";
44
import { trpc } from "@/utils/trpc";
55
import { use } from "react";
6+
import { Button } from "@/components/ui/button";
67

78
type Props = {
89
params: Promise<{ packageId: string }>;
@@ -11,6 +12,12 @@ type Props = {
1112
export default function PackageDetails(props: Props) {
1213
const params = use(props.params);
1314

15+
const startPublishing = trpc.packages.startPublishing.useMutation({
16+
onSuccess: () => {
17+
alert("Publishing started");
18+
},
19+
});
20+
1421
const { data: packageDetails, isLoading } =
1522
trpc.packages.getPackageDetails.useQuery({
1623
packageId: parseInt(params.packageId),
@@ -56,6 +63,18 @@ export default function PackageDetails(props: Props) {
5663
Manage Members
5764
</Link>
5865
</div>
66+
67+
<div>
68+
<Button
69+
onClick={() => {
70+
void startPublishing.mutateAsync({
71+
packageId: parseInt(params.packageId),
72+
});
73+
}}
74+
>
75+
Demo: Start publishing your package
76+
</Button>
77+
</div>
5978
</div>
6079
);
6180
}

0 commit comments

Comments
 (0)