diff --git a/api/db/migrations/1775081823814-event-whitelist.ts b/api/db/migrations/1775081823814-event-whitelist.ts new file mode 100644 index 00000000..d85b470d --- /dev/null +++ b/api/db/migrations/1775081823814-event-whitelist.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class EventWhitelist1775081823814 implements MigrationInterface { + name = 'EventWhitelist1775081823814' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "event_whitelists" DROP CONSTRAINT "FK_event_whitelists_event"`); + await queryRunner.query(`DROP INDEX "public"."UQ_event_whitelists_event_username_platform"`); + await queryRunner.query(`ALTER TABLE "event_whitelists" ALTER COLUMN "eventId" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "event_whitelists" ADD CONSTRAINT "UQ_1e66e3e9750244ea3f6563a6d49" UNIQUE ("eventId", "username", "platform")`); + await queryRunner.query(`ALTER TABLE "event_whitelists" ADD CONSTRAINT "FK_17ccbcee3723a35d6d691532c22" FOREIGN KEY ("eventId") REFERENCES "events"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "event_whitelists" DROP CONSTRAINT "FK_17ccbcee3723a35d6d691532c22"`); + await queryRunner.query(`ALTER TABLE "event_whitelists" DROP CONSTRAINT "UQ_1e66e3e9750244ea3f6563a6d49"`); + await queryRunner.query(`ALTER TABLE "event_whitelists" ALTER COLUMN "eventId" SET NOT NULL`); + await queryRunner.query(`CREATE UNIQUE INDEX "UQ_event_whitelists_event_username_platform" ON "event_whitelists" ("eventId", "platform", "username") `); + await queryRunner.query(`ALTER TABLE "event_whitelists" ADD CONSTRAINT "FK_event_whitelists_event" FOREIGN KEY ("eventId") REFERENCES "events"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + +} diff --git a/api/src/event/dtos/whitelistDto.ts b/api/src/event/dtos/whitelistDto.ts new file mode 100644 index 00000000..b3fdbfc0 --- /dev/null +++ b/api/src/event/dtos/whitelistDto.ts @@ -0,0 +1,24 @@ +import { IsArray, IsEnum, IsString, ValidateNested } from "class-validator"; +import { Type } from "class-transformer"; +import { WhitelistPlatform } from "../entities/event-whitelist.entity"; + +export class WhitelistEntryDto { + @IsString() + username: string; + + @IsEnum(WhitelistPlatform) + platform: WhitelistPlatform; +} + +export class AddToWhitelistDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => WhitelistEntryDto) + entries: WhitelistEntryDto[]; +} + +export class BulkDeleteWhitelistDto { + @IsArray() + @IsString({ each: true }) + ids: string[]; +} diff --git a/api/src/event/entities/event-whitelist.entity.ts b/api/src/event/entities/event-whitelist.entity.ts new file mode 100644 index 00000000..78cfc927 --- /dev/null +++ b/api/src/event/entities/event-whitelist.entity.ts @@ -0,0 +1,35 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + ManyToOne, + Unique, +} from "typeorm"; +import { EventEntity } from "./event.entity"; + +export enum WhitelistPlatform { + GITHUB = "GITHUB", + FORTYTWO = "FORTYTWO", +} + +@Entity("event_whitelists") +@Unique(["event", "username", "platform"]) +export class EventWhitelistEntity { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column({ type: "enum", enum: WhitelistPlatform }) + platform: WhitelistPlatform; + + @Column() + username: string; + + @ManyToOne(() => EventEntity, { + onDelete: "CASCADE", + }) + event: EventEntity; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/api/src/event/entities/event.entity.ts b/api/src/event/entities/event.entity.ts index 971a8a27..57734ab3 100644 --- a/api/src/event/entities/event.entity.ts +++ b/api/src/event/entities/event.entity.ts @@ -15,6 +15,7 @@ import { import { TeamEntity } from "../../team/entities/team.entity"; import { Exclude } from "class-transformer"; import { EventStarterTemplateEntity } from "./event-starter-template.entity"; +import { EventWhitelistEntity } from "./event-whitelist.entity"; @Entity("events") export class EventEntity { @@ -121,4 +122,9 @@ export class EventEntity { cascade: true, }) starterTemplates: EventStarterTemplateEntity[]; + + @OneToMany(() => EventWhitelistEntity, (whitelist) => whitelist.event, { + onDelete: "CASCADE", + }) + whitelists: EventWhitelistEntity[]; } diff --git a/api/src/event/event.controller.ts b/api/src/event/event.controller.ts index 7cb608a5..2112ebc1 100644 --- a/api/src/event/event.controller.ts +++ b/api/src/event/event.controller.ts @@ -20,6 +20,7 @@ import { SetLockTeamsDateDto } from "./dtos/setLockTeamsDateDto"; import { UpdateEventSettingsDto } from "./dtos/updateEventSettingsDto"; import { CreateEventStarterTemplateDto } from "./dtos/createEventStarterTemplateDto"; import { UpdateEventStarterTemplateDto } from "./dtos/updateEventStarterTemplateDto"; +import { AddToWhitelistDto, BulkDeleteWhitelistDto } from "./dtos/whitelistDto"; import { JwtAuthGuard } from "../auth/jwt-auth.guard"; import { UserId } from "../guards/UserGuard"; @@ -169,6 +170,28 @@ export class EventController { ); } + const event = await this.eventService.getEventById(eventId); + + if (event.isPrivate) { + const user = await this.userService.getUserById(userId); + const socialAccounts = user.socialAccounts || []; + const fortyTwoAccount = socialAccounts.find( + (sa) => sa.platform === "42", + ); + + const isWhitelisted = await this.eventService.isUserWhitelistedForEvent( + eventId, + user.username, + fortyTwoAccount?.username || null, + ); + + if (!isWhitelisted) { + throw new UnauthorizedException( + "You are not whitelisted for this private event.", + ); + } + } + this.logger.log({ action: "attempt_join_event", userId, eventId }); return this.userService.joinEvent(userId, eventId); @@ -377,4 +400,79 @@ export class EventController { }); return this.eventService.deleteStarterTemplate(eventId, templateId); } + + @UseGuards(JwtAuthGuard) + @Get(":id/whitelist") + async getWhitelist( + @Param("id", new ParseUUIDPipe()) eventId: string, + @UserId() userId: string, + ) { + if (!(await this.eventService.isEventAdmin(eventId, userId))) { + throw new UnauthorizedException("You are not an admin of this event"); + } + return this.eventService.getWhitelist(eventId); + } + + @UseGuards(JwtAuthGuard) + @Post(":id/whitelist") + async addToWhitelist( + @Param("id", new ParseUUIDPipe()) eventId: string, + @UserId() userId: string, + @Body() body: AddToWhitelistDto, + ) { + if (!(await this.eventService.isEventAdmin(eventId, userId))) { + throw new UnauthorizedException("You are not an admin of this event"); + } + + this.logger.log({ + action: "attempt_add_to_whitelist", + userId, + eventId, + entriesCount: body.entries.length, + }); + + return this.eventService.addToWhitelist(eventId, body.entries); + } + + @UseGuards(JwtAuthGuard) + @Delete(":id/whitelist/:whitelistId") + async removeFromWhitelist( + @Param("id", new ParseUUIDPipe()) eventId: string, + @Param("whitelistId", new ParseUUIDPipe()) whitelistId: string, + @UserId() userId: string, + ) { + if (!(await this.eventService.isEventAdmin(eventId, userId))) { + throw new UnauthorizedException("You are not an admin of this event"); + } + + this.logger.log({ + action: "attempt_remove_from_whitelist", + userId, + eventId, + whitelistId, + }); + + return this.eventService.removeFromWhitelist(eventId, whitelistId); + } + + @UseGuards(JwtAuthGuard) + @Post(":id/whitelist/bulk-delete") + async bulkRemoveFromWhitelist( + @Param("id", new ParseUUIDPipe()) eventId: string, + @UserId() userId: string, + @Body() body: BulkDeleteWhitelistDto, + ) { + if (!(await this.eventService.isEventAdmin(eventId, userId))) { + throw new UnauthorizedException("You are not an admin of this event"); + } + + this.logger.log({ + action: "attempt_bulk_remove_from_whitelist", + userId, + eventId, + idsCount: body.ids.length, + }); + + return this.eventService.bulkRemoveFromWhitelist(eventId, body.ids); + } } diff --git a/api/src/event/event.module.ts b/api/src/event/event.module.ts index 799c1896..2b876cc8 100644 --- a/api/src/event/event.module.ts +++ b/api/src/event/event.module.ts @@ -9,6 +9,7 @@ import { UserEventPermissionEntity } from "../user/entities/user.entity"; import { CheckController } from "./check.controller"; import { EventStarterTemplateEntity } from "./entities/event-starter-template.entity"; +import { EventWhitelistEntity } from "./entities/event-whitelist.entity"; @Module({ imports: [ @@ -16,6 +17,7 @@ import { EventStarterTemplateEntity } from "./entities/event-starter-template.en EventEntity, UserEventPermissionEntity, EventStarterTemplateEntity, + EventWhitelistEntity, ]), UserModule, forwardRef(() => TeamModule), diff --git a/api/src/event/event.service.ts b/api/src/event/event.service.ts index fbf59aab..cffd0ee3 100644 --- a/api/src/event/event.service.ts +++ b/api/src/event/event.service.ts @@ -10,8 +10,10 @@ import { import { InjectRepository } from "@nestjs/typeorm"; import { EventEntity } from "./entities/event.entity"; import { EventStarterTemplateEntity } from "./entities/event-starter-template.entity"; +import { EventWhitelistEntity } from "./entities/event-whitelist.entity"; import { DataSource, + In, IsNull, LessThanOrEqual, MoreThanOrEqual, @@ -29,6 +31,7 @@ import { FindOptionsRelations } from "typeorm/find-options/FindOptionsRelations" import { Cron, CronExpression } from "@nestjs/schedule"; import { EventVersionDto } from "./dtos/eventVersionDto"; import { LockKeys } from "../constants"; +import { WhitelistPlatform } from "./entities/event-whitelist.entity"; @Injectable() export class EventService { @@ -39,6 +42,8 @@ export class EventService { private readonly permissionRepository: Repository, @InjectRepository(EventStarterTemplateEntity) private readonly templateRepository: Repository, + @InjectRepository(EventWhitelistEntity) + private readonly whitelistRepository: Repository, private readonly configService: ConfigService, @Inject(forwardRef(() => TeamService)) private readonly teamService: TeamService, @@ -666,4 +671,143 @@ export class EventService { canCreateTeam: true, }); } + + async getWhitelist(eventId: string): Promise { + return this.whitelistRepository.find({ + where: { event: { id: eventId } }, + order: { createdAt: "DESC" }, + }); + } + + async addToWhitelist( + eventId: string, + entries: { username: string; platform: WhitelistPlatform }[], + ): Promise { + const event = await this.getEventById(eventId); + const normalizedEntries = entries.map((entry) => ({ + username: entry.username.toLowerCase(), + platform: entry.platform, + })); + + const uniqueInputEntries = [ + ...new Map( + normalizedEntries.map((e) => [`${e.username}:${e.platform}`, e]), + ).values(), + ]; + + const existingEntries = await this.whitelistRepository.find({ + where: { + event: { id: eventId }, + }, + }); + + const existingSet = new Set( + existingEntries.map((e) => `${e.username}:${e.platform}`), + ); + + const newEntries = uniqueInputEntries.filter( + (entry) => !existingSet.has(`${entry.username}:${entry.platform}`), + ); + + if (newEntries.length === 0) { + return []; + } + + const whitelistEntries = newEntries.map((entry) => + this.whitelistRepository.create({ + event, + username: entry.username, + platform: entry.platform, + }), + ); + + return this.whitelistRepository.save(whitelistEntries); + } + + async removeFromWhitelist( + eventId: string, + whitelistId: string, + ): Promise { + await this.whitelistRepository.delete({ + id: whitelistId, + event: { id: eventId }, + }); + } + + async bulkRemoveFromWhitelist( + eventId: string, + ids: string[], + ): Promise { + await this.whitelistRepository.delete({ + id: In(ids), + event: { id: eventId }, + }); + } + + async hasWhitelist(eventId: string): Promise { + return this.whitelistRepository.existsBy({ + event: { id: eventId }, + }); + } + + async isUserWhitelisted( + eventId: string, + githubUsername: string, + fortyTwoUsername: string | null, + ): Promise { + const conditions: { username: string; platform: WhitelistPlatform }[] = [ + { username: githubUsername.toLowerCase(), platform: WhitelistPlatform.GITHUB }, + ]; + + if (fortyTwoUsername) { + conditions.push({ + username: fortyTwoUsername.toLowerCase(), + platform: WhitelistPlatform.FORTYTWO, + }); + } + + return this.whitelistRepository.existsBy({ + event: { id: eventId }, + ...conditions.reduce( + (acc, cond, idx) => { + if (idx === 0) { + return { username: cond.username, platform: cond.platform }; + } + return acc; + }, + {} as { username?: string; platform?: WhitelistPlatform }, + ), + }); + } + + async isUserWhitelistedForEvent( + eventId: string, + githubUsername: string, + fortyTwoUsername: string | null, + ): Promise { + const hasWhitelist = await this.hasWhitelist(eventId); + if (!hasWhitelist) { + return true; + } + + const queryBuilder = this.whitelistRepository + .createQueryBuilder("whitelist") + .where("whitelist.eventId = :eventId", { eventId }) + .andWhere( + "(whitelist.username = :githubUsername AND whitelist.platform = :githubPlatform)", + { githubUsername: githubUsername.toLowerCase(), githubPlatform: WhitelistPlatform.GITHUB }, + ); + + if (fortyTwoUsername) { + queryBuilder.orWhere( + "(whitelist.username = :fortyTwoUsername AND whitelist.platform = :fortyTwoPlatform)", + { + fortyTwoUsername: fortyTwoUsername.toLowerCase(), + fortyTwoPlatform: WhitelistPlatform.FORTYTWO, + }, + ); + } + + return queryBuilder.getExists(); + } } diff --git a/api/src/user/user.service.ts b/api/src/user/user.service.ts index 7b1f47ed..3c9b0aa4 100644 --- a/api/src/user/user.service.ts +++ b/api/src/user/user.service.ts @@ -111,8 +111,9 @@ export class UserService { } getUserById(userId: string) { - return this.userRepository.findOneByOrFail({ - id: userId, + return this.userRepository.findOneOrFail({ + where: { id: userId }, + relations: ["socialAccounts"], }); } diff --git a/frontend/app/actions/event.ts b/frontend/app/actions/event.ts index da4cb41f..672d7f6c 100644 --- a/frontend/app/actions/event.ts +++ b/frontend/app/actions/event.ts @@ -91,9 +91,8 @@ export async function getParticipantsCountForEvent( } // Join a user to an event -export async function joinEvent(eventId: string): Promise { - await axiosInstance.put(`event/${eventId}/join`); - return true; +export async function joinEvent(eventId: string): Promise> { + return await handleError(axiosInstance.put(`event/${eventId}/join`)); } // Interface for creating events @@ -243,3 +242,43 @@ export async function deleteStarterTemplate( axiosInstance.delete(`event/${eventId}/templates/${templateId}`), ); } + +export interface WhitelistEntry { + id: string; + username: string; + platform: "GITHUB" | "FORTYTWO"; + createdAt: string; +} + +export async function getEventWhitelist( + eventId: string, +): Promise> { + return await handleError(axiosInstance.get(`event/${eventId}/whitelist`)); +} + +export async function addToWhitelist( + eventId: string, + entries: { username: string; platform: "GITHUB" | "FORTYTWO" }[], +): Promise> { + return await handleError( + axiosInstance.post(`event/${eventId}/whitelist`, { entries }), + ); +} + +export async function removeFromWhitelist( + eventId: string, + whitelistId: string, +): Promise> { + return await handleError( + axiosInstance.delete(`event/${eventId}/whitelist/${whitelistId}`), + ); +} + +export async function bulkRemoveFromWhitelist( + eventId: string, + ids: string[], +): Promise> { + return await handleError( + axiosInstance.post(`event/${eventId}/whitelist/bulk-delete`, { ids }), + ); +} diff --git a/frontend/app/events/[id]/dashboard/components/WhitelistManagement.tsx b/frontend/app/events/[id]/dashboard/components/WhitelistManagement.tsx new file mode 100644 index 00000000..93ea5e6a --- /dev/null +++ b/frontend/app/events/[id]/dashboard/components/WhitelistManagement.tsx @@ -0,0 +1,280 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { isActionError } from "@/app/actions/errors"; +import { + addToWhitelist, + bulkRemoveFromWhitelist, + getEventWhitelist, + removeFromWhitelist, +} from "@/app/actions/event"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Textarea } from "@/components/ui/textarea"; + +interface WhitelistManagementProps { + eventId: string; +} + +export function WhitelistManagement({ eventId }: WhitelistManagementProps) { + const queryClient = useQueryClient(); + const [usernames, setUsernames] = useState(""); + const [platform, setPlatform] = useState<"GITHUB" | "FORTYTWO">("GITHUB"); + const [selectedIds, setSelectedIds] = useState>(new Set()); + + const { data: whitelist = [], isLoading } = useQuery({ + queryKey: ["event", eventId, "whitelist"], + queryFn: async () => { + const result = await getEventWhitelist(eventId); + if (isActionError(result)) + throw new Error(result.error); + return result; + }, + }); + + const addMutation = useMutation({ + mutationFn: async (entries: { username: string; platform: "GITHUB" | "FORTYTWO" }[]) => { + const result = await addToWhitelist(eventId, entries); + if (isActionError(result)) + throw new Error(result.error); + return result; + }, + onSuccess: () => { + toast.success("Users added to whitelist"); + setUsernames(""); + queryClient.invalidateQueries({ queryKey: ["event", eventId, "whitelist"] }); + }, + onError: (e: any) => toast.error(e.message), + }); + + const removeMutation = useMutation({ + mutationFn: async (whitelistId: string) => { + const result = await removeFromWhitelist(eventId, whitelistId); + if (isActionError(result)) + throw new Error(result.error); + return result; + }, + onSuccess: () => { + toast.success("User removed from whitelist"); + queryClient.invalidateQueries({ queryKey: ["event", eventId, "whitelist"] }); + }, + onError: (e: any) => toast.error(e.message), + }); + + const bulkRemoveMutation = useMutation({ + mutationFn: async (ids: string[]) => { + const result = await bulkRemoveFromWhitelist(eventId, ids); + if (isActionError(result)) + throw new Error(result.error); + return result; + }, + onSuccess: () => { + toast.success("Users removed from whitelist"); + setSelectedIds(new Set()); + queryClient.invalidateQueries({ queryKey: ["event", eventId, "whitelist"] }); + }, + onError: (e: any) => toast.error(e.message), + }); + + const handleAdd = () => { + const lines = usernames + .split("\n") + .map(line => line.trim().toLowerCase()) + .filter(line => line.length > 0); + + if (lines.length === 0) { + toast.error("Please enter at least one username"); + return; + } + + const entries = lines.map(username => ({ username, platform })); + addMutation.mutate(entries); + }; + + const toggleSelect = (id: string) => { + const newSelected = new Set(selectedIds); + if (newSelected.has(id)) { + newSelected.delete(id); + } + else { + newSelected.add(id); + } + setSelectedIds(newSelected); + }; + + const toggleSelectAll = () => { + if (selectedIds.size === whitelist.length) { + setSelectedIds(new Set()); + } + else { + setSelectedIds(new Set(whitelist.map(w => w.id))); + } + }; + + const handleBulkDelete = () => { + if (selectedIds.size === 0) { + toast.error("Please select users to delete"); + return; + } + bulkRemoveMutation.mutate(Array.from(selectedIds)); + }; + + return ( + + + Whitelist + + Manage which users can join this private event. If the whitelist is empty, anyone with the link can join. + + + + {isLoading + ? ( +
+ +
+ ) + : ( + <> +
+ + + + + 0 && selectedIds.size === whitelist.length} + onCheckedChange={toggleSelectAll} + /> + + Username + Platform + Actions + + + + {whitelist.length === 0 + ? ( + + + No users whitelisted. Anyone with the event link can join. + + + ) + : ( + whitelist.map(entry => ( + + + toggleSelect(entry.id)} + /> + + {entry.username} + + + {entry.platform === "GITHUB" ? "GitHub" : "42"} + + + + + + + )) + )} + +
+
+ + {whitelist.length > 0 && selectedIds.size > 0 && ( +
+ +
+ )} + +
+

Add Users to Whitelist

+
+
+ +