Skip to content
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
3 changes: 0 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,3 @@ DISCORD_CLIENT_SECRET=someclientsecret
GITHUB_CLIENT_ID=someclientid
GITHUB_CLIENT_SECRET=someclientsecret
DRIVE_DISK=s3
AWS_ACCESS_KEY_ID=awskeyid
AWS_SECRET_ACCESS_KEY=awssecretaccesskey
AWS_REGION=your-region
20 changes: 18 additions & 2 deletions app/controllers/teams_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import type { HttpContext } from '@adonisjs/core/http'
import {
createTeamValidator,
kickMemberValidator,
teamInvitationResponseValidator,
teamInvitationValidator,
updateTeamValidator,
Expand Down Expand Up @@ -64,8 +65,10 @@ export default class TeamsController {
* Handle form submission for the creation action
*/
async store({ auth, bouncer, params, request, response }: HttpContext) {
const { accessCode, ...payload } = await request.validateUsing(createTeamValidator)
const event = await Event.findByUuidOrSlug(params.event_id)
const { accessCode, ...payload } = await request.validateUsing(createTeamValidator, {
meta: { eventId: event.id },
})
await bouncer.with(TeamPolicy).authorize('create', event, accessCode)

const team = await db.transaction(async (trx) => {
Expand Down Expand Up @@ -115,7 +118,10 @@ export default class TeamsController {
const { params } = await request.validateUsing(paramsIdValidator)
const team = await Team.findOrFail(params.id)
const payload = await request.validateUsing(updateTeamValidator, {
meta: { teamName: team.name }
meta: {
teamName: team.name,
eventId: team.eventId
}
})

await bouncer.with(TeamPolicy).authorize('edit', team)
Expand Down Expand Up @@ -145,6 +151,16 @@ export default class TeamsController {
return response.noContent()
}

// Kick team member by member uuid
async kickMember({ bouncer, request, response }: HttpContext) {
const { member, params } = await request.validateUsing(kickMemberValidator)
const team = await Team.findOrFail(params.id)
await bouncer.with(TeamPolicy).authorize('kick', team, member)

await team.related('members').query().where('id', member).delete()
return response.noContent()
}

// Create a new invite
async storeInvite({ auth, bouncer, request }: HttpContext) {
const { params } = await request.validateUsing(paramsIdValidator)
Expand Down
20 changes: 17 additions & 3 deletions app/policies/team_policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { TeamMemberGuard, UserGuard } from '#utils/permissions'
import Team from "#models/team/team";
import TeamInvitation from "#models/team/team_invitation";
import { DateTime } from "luxon";
import TeamMember from "#models/team/team_member";

export default class TeamPolicy extends BasePolicy {
// Whether a user can create a new team for an event
Expand Down Expand Up @@ -67,6 +68,19 @@ export default class TeamPolicy extends BasePolicy {
return TeamMemberGuard.can(member, 'REGISTER_TASK')
}

async kick(user: User, team: Team, targetMemberId: string): Promise<AuthorizerResponse> {
const member = await TeamMember.findOrFail(targetMemberId)
if (TeamMemberGuard.can(member, 'IS_OWNER'))
return AuthorizationResponse.deny(
'Owner cannot be kicked or leave. Please delete the team or transfer ownership to another user.'
)

if (UserGuard.can(user, 'MANAGE_ALL_TEAMS'))
return true

return member.userId === user.id || this.edit(user, team)
}

async invite(user: User, team: Team, otherUserEmail?: string): Promise<AuthorizerResponse> {
if (otherUserEmail) {
const foundMembers = await team.related('members')
Expand All @@ -79,9 +93,9 @@ export default class TeamPolicy extends BasePolicy {
return AuthorizationResponse.deny('User is already a member of this team')
}

const members = await team.related('members').query().count('id')
const members = await team.related('members').query().count('*', 'count').firstOrFail()
const event = await team.related('event').query().firstOrFail()
if (members.length >= event.maxTeamSize)
if (members.$extras.count >= event.maxTeamSize)
return AuthorizationResponse.deny('Team already has maximum number of members')

return this.edit(user, team)
Expand All @@ -99,7 +113,7 @@ export default class TeamPolicy extends BasePolicy {
if (invitation.expiresAt! < now) {
invitation.status = 'EXPIRED'
await invitation.save()
return AuthorizationResponse.deny('Invitation is expired')
return AuthorizationResponse.deny('Invitation is expired', 422)
}

if (invitation.inviteeEmail && invitation.inviteeEmail !== user.email)
Expand Down
33 changes: 17 additions & 16 deletions app/utils/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,32 +51,33 @@ const PermissionsGuard = <E extends Record<string, string | number>>(maskEnum: E
PERMISSIONS BEGIN HERE
*/
export enum UserPermissions {
CREATE_EVENT = 1 << 0, // Whether user can create a new event
MANAGE_ALL_EVENTS = 1 << 1, // Whether user can manage all events on platform (administrative)
CREATE_EVENT = 1 << 0, // Whether a user can create a new event
MANAGE_ALL_EVENTS = 1 << 1, // Whether a user can manage all events on the platform (administrative)
CREATE_TEAM = 1 << 2, // Whether user can create teams for events
MANAGE_ALL_TEAMS = 1 << 3, // Whether user can manage all teams on platform (administrative)
MANAGE_ALL_TASKS = 1 << 4, // Whether user can manage all tasks on platform
MANAGE_ALL_TEAMS = 1 << 3, // Whether a user can manage all teams on the platform (administrative)
MANAGE_ALL_TASKS = 1 << 4, // Whether a user can manage all tasks on the platform
}
export const UserGuard = PermissionsGuard(UserPermissions)

export enum TeamMemberPermissions {
MANAGE_MEMBERS = 1 << 0, // Whether member can invite, remove and change other member permissions
REGISTER_TASK = 1 << 1, // Whether member can register team for tasks
MANAGE_MEMBERS = 1 << 0, // Whether a member can invite, remove and change other member permissions
REGISTER_TASK = 1 << 1, // Whether a member can register the team for tasks
SUBMIT_TASK = 1 << 2, // Whether member can submit tasks
MANAGE_TEAM = 1 << 3, // Whether user can edit and/or delete team
MANAGE_TEAM = 1 << 3, // Whether a user can edit and/or delete the team
IS_OWNER = 1 << 30, // Whether a user is a team owner (leftmost bit for signed 32-bit integer)
}

export const TeamMemberGuard = PermissionsGuard(TeamMemberPermissions)

export enum EventAdminPermissions {
MANAGE_EVENT = 1 << 0, // Whether user can rename event, change description, etc...
CREATE_TASK = 1 << 1, // Whether user can create new tasks for an event
MANAGE_ALL_TASKS = 1 << 2, // Whether user can remove, and edit all tasks
VIEW_DRAFT = 1 << 3, // Whether user can see and edit event in draft state
MANAGE_ADMINS = 1 << 4, // Whether user can assign/revoke other event administrators
MANAGE_ORGANIZATIONS = 1 << 5, // Whether user can manage organizations for the event
MANAGE_SPONSORS = 1 << 6, // Whether user can manage sponsors for the event
MANAGE_JURY_MEMBERS = 1 << 7, // Whether user can manage jury members for the events tasks
EDIT_TASK = 1 << 8, // Whether user can edit tasks (except for those with MANAGE_ALL_TASKS)
MANAGE_EVENT = 1 << 0, // Whether a user can rename an event, change description, etc...
CREATE_TASK = 1 << 1, // Whether a user can create new tasks for an event
MANAGE_ALL_TASKS = 1 << 2, // Whether a user can remove and edit all tasks
VIEW_DRAFT = 1 << 3, // Whether a user can see and edit an event in a draft state
MANAGE_ADMINS = 1 << 4, // Whether a user can assign/revoke other event administrators
MANAGE_ORGANIZATIONS = 1 << 5, // Whether a user can manage organizations for the event
MANAGE_SPONSORS = 1 << 6, // Whether a user can manage sponsors for the event
MANAGE_JURY_MEMBERS = 1 << 7, // Whether a user can manage jury members for the events tasks
EDIT_TASK = 1 << 8, // Whether a user can edit tasks (except for those with MANAGE_ALL_TASKS)
}
export const EventAdminGuard = PermissionsGuard(EventAdminPermissions)
17 changes: 14 additions & 3 deletions app/validators/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ const teamSchema = {
export const createTeamValidator = vine.compile(
vine.object({
...teamSchema,
name: vine.string().trim().unique(async (db, value) => {
const match = await db.from('teams').where('name', value).first()
name: vine.string().trim().unique(async (db, value, field) => {
const eventId = field.meta.eventId
const match = await db.from('teams').where('name', value).where('event_id', eventId).first()
return !match
}),
accessCode: vine.string().optional(),
Expand All @@ -45,12 +46,22 @@ export const updateTeamValidator = vine.compile(
name: vine.string().trim().unique(async (db, value, field) => {
if (value === field.meta.teamName)
return true // Ignore name collision with self
const match = await db.from('teams').where('name', value).first()
const eventId = field.meta.eventId
const match = await db.from('teams').where('name', value).where('event_id', eventId).first()
return !match
}),
})
)

export const kickMemberValidator = vine.compile(
vine.object({
member: vine.string().uuid(),
params: vine.object({
id: vine.string().uuid(),
}),
})
)

export const teamInvitationValidator = vine.compile(
vine.object({
email: vine.string().email().trim().optional(),
Expand Down
2 changes: 1 addition & 1 deletion config/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const sessionConfig = defineConfig({
* variable in order to infer the store name without any
* errors.
*/
store: env.get('SESSION_DRIVER'),
store: app.inTest ? 'memory' : env.get('SESSION_DRIVER'),

/**
* List of configured stores. Refer documentation to see
Expand Down
60 changes: 60 additions & 0 deletions database/factories/team_factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* ______ __ __
* _ __/ ____/___ ____ / /____ _____/ /_
* | |/_/ / / __ \/ __ \/ __/ _ \/ ___/ __/
* _> </ /___/ /_/ / / / / /_/ __(__ ) /_
* /_/|_|\____/\____/_/ /_/\__/\___/____/\__/
* Copyright (C) 2026 xContest Team
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*
*/

import factory from '@adonisjs/lucid/factories'
import Team from '#models/team/team'
import TeamMember from "#models/team/team_member";
import { TeamMemberGuard } from "#utils/permissions";
import { UserFactory } from "#database/factories/user_factory";
import Event from "#models/event/event";
import TeamInvitation from "#models/team/team_invitation";
import { DateTime } from "luxon";

export const TeamFactory = factory
.define(Team, async ({ faker }) => ({
// eslint-disable-next-line @unicorn/no-await-expression-member
eventId: (await Event.findByOrFail('slug', 'no-tasks')).id,
name: faker.company.name(),
}))
.relation('members', () => TeamMemberFactory)
.after('create', async (_, { members }) => {
if (members.length > 0)
// Set the first member to be an admin
members[0].permissions = TeamMemberGuard.allPermissions()
await members[0].save()
})
.build()

export const TeamMemberFactory = factory
.define(TeamMember, async ({ }) => ({
permissions: TeamMemberGuard.build(),
}))
.relation('user', () => UserFactory)
.build()

export const InvitationFactory = factory
.define(TeamInvitation, async ({ faker }) => ({
token: faker.internet.password({ length: 16, memorable: true }),
expiresAt: DateTime.now().plus({ days: 1 }),
}))
.build()
2 changes: 2 additions & 0 deletions database/migrations/1771616944000_create_teams_table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export default class extends BaseSchema {

table.timestamp('created_at').notNullable()
table.timestamp('updated_at').nullable()

table.unique(['name', 'event_id']) // Prevent same named teams in a single event
})
}

Expand Down
33 changes: 28 additions & 5 deletions database/seeders/2_team_seeder.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,26 @@
/*
* ______ __ __
* _ __/ ____/___ ____ / /____ _____/ /_
* | |/_/ / / __ \/ __ \/ __/ _ \/ ___/ __/
* _> </ /___/ /_/ / / / / /_/ __(__ ) /_
* /_/|_|\____/\____/_/ /_/\__/\___/____/\__/
* Copyright (C) 2026 xContest Team
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*
*/

import Team from '#models/team/team'
import User from '#models/user'
import Event from '#models/event/event'
Expand All @@ -7,25 +30,25 @@ import { TeamMemberGuard } from '#utils/permissions'
export default class extends BaseSeeder {
async run() {
const admin = await User.findBy('nickname', 'admin')
if (!admin)
if (!admin)
throw new Error('Admin user not found. Please run UserSeeder first.')

const user = await User.findBy('nickname', 'user')
if (!user)
if (!user)
throw new Error('User not found. Please run UserSeeder first.')

const hackathonEvent = await Event.findByUuidOrSlug('hackathon-tasks')
if(!hackathonEvent)
if(!hackathonEvent)
throw new Error('Hackathon event not found. Please run EventSeeder first.')

const hackathonTeam = await Team.create({
eventId: hackathonEvent.id,
name: `User's team`
})

hackathonTeam.related('members').create({
await hackathonTeam.related('members').create({
userId: user.id,
permissions: TeamMemberGuard.allPermissions() // User is a team admin
})
}
}
}
1 change: 1 addition & 0 deletions start/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ router.group(() => {
router.group(() => {
router.post('/invites', [TeamsController, 'storeInvite'])
router.get('/invites', [TeamsController, 'indexInvites'])
router.post('/kick', [TeamsController, 'kickMember'])
}).prefix('/teams/:id').use(middleware.auth())
router.group(() => {
router.get('/:id', [TeamsController, 'respondToInvite'])
Expand Down
14 changes: 11 additions & 3 deletions tests/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import app from '@adonisjs/core/services/app'
import type { Config } from '@japa/runner/types'
import { pluginAdonisJS } from '@japa/plugin-adonisjs'
import testUtils from '@adonisjs/core/services/test_utils'
import { authApiClient } from "@adonisjs/auth/plugins/api_client";
import { sessionApiClient } from '@adonisjs/session/plugins/api_client'

/**
* This file is imported by the "bin/test.ts" entrypoint file
Expand All @@ -36,7 +38,13 @@ import testUtils from '@adonisjs/core/services/test_utils'
* Configure Japa plugins in the plugins array.
* Learn more - https://japa.dev/docs/runner-config#plugins-optional
*/
export const plugins: Config['plugins'] = [assert(), apiClient(), pluginAdonisJS(app)]
export const plugins: Config['plugins'] = [
assert(),
apiClient(),
pluginAdonisJS(app),
sessionApiClient(app),
authApiClient(app)
]

/**
* Configure lifecycle function to run before and after all the
Expand All @@ -55,7 +63,7 @@ export const runnerHooks: Required<Pick<Config, 'setup' | 'teardown'>> = {
* Learn more - https://japa.dev/docs/test-suites#lifecycle-hooks
*/
export const configureSuite: Config['configureSuite'] = (suite) => {
if (['browser', 'functional', 'e2e'].includes(suite.name))
if (['browser', 'functional', 'e2e'].includes(suite.name))
return suite.setup(() => testUtils.httpServer().start())

}
Loading