Skip to content

Commit f5a0f06

Browse files
authored
feat(admin): admin action buttons (#268)
1 parent 7931eec commit f5a0f06

File tree

6 files changed

+216
-10
lines changed

6 files changed

+216
-10
lines changed

packages/admin/pages/admin/users.vue

+45-5
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,17 @@ const badgeColors = {
3131
hacker: 'gray'
3232
} as const;
3333
34+
const mealItems = [
35+
{ label: 'meal 0', value: 0 },
36+
{ label: 'meal 1', value: 1 },
37+
{ label: 'meal 2', value: 2 }
38+
];
39+
40+
// there's a nuxt ui bug which returns a thing whose actual type is a string
41+
// even though the USelect API is says it's gonna be a number, since the
42+
// value in mealItems above says it's going to be a number. But it's not. this language sucks ass.
43+
const selectedMealNum = ref<number>(0);
44+
3445
// Admin/God self profile
3546
const { data: selfProfile } = useAsyncData<Profile>('selfProfile', async () => {
3647
const { getSelf } = useProfile();
@@ -81,8 +92,34 @@ const unlinkQRCode = async (user: FlatUserProfile) => {
8192
}
8293
};
8394
84-
const notFunctional = () => {
85-
alert('This feature is not functional yet.');
95+
const unsetMeal = async (user: FlatUserProfile) => {
96+
const { unsetMeal } = useProfile();
97+
const result = await unsetMeal(user.id, selectedMealNum.value);
98+
if (result.isError()) {
99+
return alert(result.error.message);
100+
} else {
101+
return alert(`Meal ${selectedMealNum.value} unset succesfully`);
102+
}
103+
};
104+
105+
const deleteCV = async (user: FlatUserProfile) => {
106+
const { deleteCV } = useProfile();
107+
const result = await deleteCV(user.id);
108+
if (result.isError()) {
109+
return alert(result.error.message);
110+
} else {
111+
return alert('CV deleted');
112+
}
113+
};
114+
115+
const sendResetPassword = async (user: FlatUserProfile) => {
116+
const { forgotPassword } = useAuth();
117+
const result = await forgotPassword(user.email);
118+
if (result.isError()) {
119+
return alert(result.error.message);
120+
} else {
121+
return alert('Reset password link sent successfully');
122+
}
86123
};
87124
88125
// Other misc things
@@ -222,9 +259,12 @@ useHead({
222259
<div>
223260
<h3 class="text-lg font-semibold">Other Actions</h3>
224261
<div class="mt-2 flex flex-wrap gap-4">
225-
<UButton @click="notFunctional">Send Reset Password</UButton>
226-
<UButton @click="notFunctional">Unset a Meal</UButton>
227-
<UButton @click="notFunctional">Delete CV</UButton>
262+
<UButton @click="sendResetPassword(selectedUser!)">
263+
Send Reset Password
264+
</UButton>
265+
<UButton @click="unsetMeal(selectedUser!)"> Unset meal: </UButton>
266+
<USelect v-model="selectedMealNum" :options="mealItems"></USelect>
267+
<UButton @click="deleteCV(selectedUser!)">Delete CV</UButton>
228268
</div>
229269
</div>
230270
</div>

packages/common/composables/useProfile.ts

+40-1
Original file line numberDiff line numberDiff line change
@@ -107,10 +107,49 @@ export default () => {
107107
return res.json();
108108
});
109109

110+
const unsetMeal = async (
111+
userId: string,
112+
mealNum: number
113+
): Promise<Result<void, Error>> =>
114+
Result.try(async () => {
115+
const res = await client.profile.meal.$delete({
116+
json: {
117+
userId: userId,
118+
mealNum: mealNum
119+
}
120+
});
121+
122+
if (!res.ok) {
123+
const errorMessage = await res.text();
124+
throw new Error(errorMessage);
125+
}
126+
});
127+
128+
// Only gods will be able to do this because the route requires the use of the
129+
// sudo middleware
130+
const deleteCV = async (userId: string): Promise<Result<void, Error>> =>
131+
Result.try(async () => {
132+
const res = await client.profile.cv.$delete(
133+
{},
134+
{
135+
headers: {
136+
'X-sudo-user': userId
137+
}
138+
}
139+
);
140+
141+
if (!res.ok) {
142+
const errorMessage = await res.text();
143+
throw new Error(errorMessage);
144+
}
145+
});
146+
110147
return {
111148
getProfiles,
112149
getSelf,
113150
register,
114-
getRegistrationStats
151+
getRegistrationStats,
152+
unsetMeal,
153+
deleteCV
115154
};
116155
};

scalar/api.yaml

+28
Original file line numberDiff line numberDiff line change
@@ -1125,6 +1125,34 @@ paths:
11251125
schema:
11261126
type: string
11271127
example: You do not have access to PUT /api/profile/meal
1128+
delete:
1129+
tags:
1130+
- Profile
1131+
summary: Unset the meals for a user
1132+
description: |
1133+
Allows a admin or god to unset the meals for a user.
1134+
Only admins or gods can access this route.
1135+
security:
1136+
- cookieAuth: []
1137+
parameters:
1138+
- $ref: '#/components/parameters/cookie'
1139+
responses:
1140+
'200':
1141+
description: The meals have been updated.
1142+
'403':
1143+
description: When a user other than a volunteer accesses this route.
1144+
content:
1145+
text/plain:
1146+
schema:
1147+
type: string
1148+
example: You do not have access to DELETE /api/profile/meal
1149+
'404':
1150+
description: When the given user does not exist.
1151+
content:
1152+
text/plain:
1153+
schema:
1154+
type: string
1155+
example: Failed to unset user meals - user does not exist
11281156

11291157
/profile/register:
11301158
get:

server/src/profile/__tests__/modifyUser.test.ts

+60-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { createUserWithSession } from '../../testHelpers';
99
import { eq, sql } from 'drizzle-orm';
1010
import { sha256 } from 'hono/utils/crypto';
1111
import { qrs } from '../../qr/schema';
12+
import { adminMeta } from '../../admin/schema';
1213

1314
const sessionIds: Partial<Record<Role, string>> = {};
1415
const userIds: Partial<Record<Role, string>> = {};
@@ -29,6 +30,7 @@ beforeAll(async () => {
2930
await db.execute(sql`TRUNCATE ${users} CASCADE`);
3031
await db.execute(sql`TRUNCATE ${profiles} CASCADE`);
3132
await db.execute(sql`TRUNCATE ${qrs} CASCADE`);
33+
await db.execute(sql`TRUNCATE ${adminMeta} CASCADE`);
3234

3335
for (const role of roles) {
3436
const toCreate = {
@@ -56,6 +58,8 @@ beforeAll(async () => {
5658
});
5759
}
5860
}
61+
62+
await db.insert(adminMeta).values({ mealNumber: 0, showCategories: false });
5963
});
6064

6165
describe('Profiles module > PUT /', () => {
@@ -107,7 +111,7 @@ describe('Profiles module > PUT /', () => {
107111
});
108112
});
109113

110-
describe('Profile smodule > PUT /meals', () => {
114+
describe('Profile module > PUT /meals', () => {
111115
test('volunteer can update meals', async () => {
112116
// { userId: string }
113117
let res = await baseRoute.meal.$put(
@@ -155,6 +159,61 @@ describe('Profile smodule > PUT /meals', () => {
155159
});
156160
});
157161

162+
describe('Profile module > DELETE /meals', () => {
163+
test('admin can update meals', async () => {
164+
await db
165+
.update(profiles)
166+
.set({ meals: [true, false, false] })
167+
.where(eq(profiles.id, userIds.hacker!));
168+
169+
// { userId: string }
170+
let res = await baseRoute.meal.$delete(
171+
{
172+
json: {
173+
userId: expectedUsers.hacker!.id,
174+
mealNum: 0
175+
}
176+
},
177+
{
178+
headers: {
179+
Cookie: `auth_session=${sessionIds.admin}`
180+
}
181+
}
182+
);
183+
184+
let userInDb = await db
185+
.select()
186+
.from(profiles)
187+
.where(eq(profiles.id, userIds.hacker!));
188+
189+
expect(res.status).toBe(200);
190+
expect(userInDb[0]!.meals[0]).toBeFalse();
191+
});
192+
193+
test('only volunteer can update meals', async () => {
194+
let res = await baseRoute.meal.$delete(
195+
{
196+
json: {
197+
userId: expectedUsers.hacker!.id,
198+
mealNum: 0
199+
}
200+
},
201+
{
202+
headers: {
203+
Cookie: `auth_session=${sessionIds.volunteer}`
204+
}
205+
}
206+
);
207+
208+
// @ts-expect-error code is from middleware
209+
expect(res.status).toBe(403);
210+
expect(res.text()).resolves.toBe(
211+
// @ts-ignore error message is from middleware
212+
'You do not have access to DELETE /api/profile/meal'
213+
);
214+
});
215+
});
216+
158217
describe.skip('Profiles module > GET/POST /cv', () => {
159218
test.skip('can upload & download own cv', async () => {
160219
let res = await baseRoute.cv.$get(undefined, {

server/src/profile/index.ts

+36-3
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import {
66
updateProfileSchema,
77
profiles,
88
searchUserSchema,
9-
registerProfilePostSchema
9+
registerProfilePostSchema,
10+
deleteMealSchema
1011
} from './schema';
1112
import { count, eq, ilike, isNotNull, and, lt, sql } from 'drizzle-orm';
1213
import { apiLogger } from '../logger';
@@ -23,6 +24,7 @@ import { sendEmail } from '../email';
2324
import nunjucks from 'nunjucks';
2425
import { emailTemplate, icticket } from './assets/register';
2526
import { qrs } from '../qr/schema';
27+
import { adminMeta } from '../admin/schema';
2628

2729
nunjucks.configure({ autoescape: true });
2830

@@ -265,8 +267,10 @@ const profile = factory
265267
// Toggle meal number `num`
266268
const { userId } = ctx.req.valid('json');
267269

268-
// TODO: replace with current meal number from db
269-
const mealNum = 0;
270+
const mealNumQuery = await db
271+
.select({ mealNum: adminMeta.mealNumber })
272+
.from(adminMeta);
273+
const mealNum = mealNumQuery[0]!.mealNum;
270274

271275
// Postgres is 1 indexed :|
272276
const updatedMeals = await db.execute(sql`
@@ -289,6 +293,35 @@ const profile = factory
289293
return ctx.text('', 200);
290294
}
291295
)
296+
.delete(
297+
'/meal',
298+
grantAccessTo(['admin', 'god']),
299+
simpleValidator('json', deleteMealSchema),
300+
async ctx => {
301+
// This mealNum is 0-indexed, to be consistent with the PUT /profiles/meal
302+
const { userId, mealNum } = ctx.req.valid('json');
303+
304+
// Postgres is 1 indexed!
305+
const updatedMeals = await db.execute(sql`
306+
UPDATE profiles
307+
SET meals[${mealNum + 1}] = false
308+
WHERE id = ${userId}`);
309+
310+
if (updatedMeals.rowCount == null) {
311+
apiLogger.error(
312+
ctx,
313+
'DELETE /profile/meal',
314+
`User ${userId} does not exist in the database.`
315+
);
316+
return ctx.text(
317+
'Failed to unset user meals - user does not exist.',
318+
404
319+
);
320+
}
321+
322+
return ctx.text('', 200);
323+
}
324+
)
292325
.get(
293326
'/discord',
294327
grantAccessTo(['authenticated'], { allowUnlinkedHackers: true }),

server/src/profile/schema.ts

+7
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@ export const updateMealSchema = z
7777
})
7878
.strict();
7979

80+
export const deleteMealSchema = z
81+
.object({
82+
userId: z.string(),
83+
mealNum: z.coerce.number().int().lte(2).gte(0)
84+
})
85+
.strict();
86+
8087
// searchUser requires an email or a name
8188
// there is a minimum length so the volunteer cannot essentially GET /all
8289
export const searchUserSchema = z

0 commit comments

Comments
 (0)