diff --git a/README.md b/README.md index fd2eeeda..2a6e1cd1 100644 --- a/README.md +++ b/README.md @@ -1,307 +1,41 @@ -# tilt +

-[![Docker Image Size (latest)](https://img.shields.io/docker/image-size/hackaburg/tilt/latest)](https://hub.docker.com/r/hackaburg/tilt) -[![codecov](https://codecov.io/gh/hackaburg/tilt/branch/main/graph/badge.svg)](https://codecov.io/gh/hackaburg/tilt) -[![David](https://img.shields.io/david/hackaburg/tilt)](https://github.com/hackaburg/tilt) -[![GitHub license](https://img.shields.io/github/license/hackaburg/tilt.svg)](https://github.com/hackaburg/tilt/LICENSE) +

tilt

-Yet another hackathon registration system. +

+ Hackathon Registration System +

-[Docker Development quickstart.md](quickstart.md) +

Documentation - Docker Development Quickstart

-## Motivation +

+ + Docker Image Size (latest) + -Like many other hackathons, we previously used [Quill](https://github.com/techx/quill) for our application process, which worked really well for us in the past. Especially Quill's process was a blessing: an application consists of two steps, the profile creation and, once an attendee was admitted to the event, the spot confirmation. We attended different events that used different processes and found this to be easy for both the attendees and organizers. - -Faced with maintaining our [fork](https://github.com/hackaburg/quill) with our set of changes to the application process, as well as maintaining an Angular.JS and an untyped Express backend, we wanted to build ourselves a registration system that matched our needs on a tech stack we're more familiar with. - -## How does tilt work? - -Similar to Quill, tilt's application process consists of the profile creation and a confirmation form. tilt, among other differences, employs a role-based model: - -- An attendee is a `user` who can fill out the forms -- A `moderator` can see applications, statistics and admit users -- `root` can modify tilt's settings - -`root` can do whatever a `moderator` can do, and a `moderator` can do whatever a `user` can do. This means each user group needs to register through the same form first and you can adjust a user's group later. During registration, tilt queries [haveibeenpwned](https://haveibeenpwned.com) to check for password breaches. - -After the setup, `root` can configure tilt's appearance, as well as the application process: - -- When should the profile form be made available? -- Until when can users submit their profile form? -- How long can users confirm their spots? - -The former two points are represented as dates, whereas the latter is represented in hours. When a user is admitted, they'll have `n` hours to confirm their spot. After this deadline passed, their application will show up as "expired". - -Each application consists of two forms, which `root` can configure. Questions have a title, a Markdown description and a type. tilt currently supports text, number, choices and country questions, which each have different configuration options. Each question can however have a parent question, which allows you to build complex, tree-like questionnaires dynamically. - -Admitting users is done through the admission page, which consists of a table and a search bar. You can search for any info an attendee might've provided and also filter for special flags such as `is:admitted` or `is:expired`. - -To admit someone, check the box on the left for as many people as you want and hit "admit". tilt will send them an email, which `root` can configure as well. The top-most checkbox depends on the visible checkboxes below. Clicking it will either select or deselect all visible rows, leaving your not shown selection intact. - -When you add a question to the profile form because you need to ask, e.g., for new terms and conditions and a user already filled out this form, there's almost no chance they'd open up their application and answer this question without you explicitly telling them to. - -Disregarding which type of question you want to add, users need to confirm their spot using the second form anyways. tilt adds all new profile questions a user cannot have seen initially to the confirmation form. This way, users need to answer these questions at last during the confirmation step and if they don't agree with, e.g., newly added terms, their spot will expire. - -## Usage - -tilt was built to be deploy-once-and-forget. For this reason, we provide a Docker image `hackaburg/tilt`. While you could run tilt natively, we highly recommend a containerized setup, as you can update it more easily. - -At Hackaburg, we run our server setup through a central NGINX proxy facing the internet and routing requests to individual containers. Given you might want to access tilt through an url like `https://your-hackathon.com/apply`, we'll show both the NGINX setup, as well as a Docker Compose configuration supporting such a setup. - -### Docker Compose - -tilt uses environment variables for configuration. There are three sections you need to configure: - -1. General settings: things like tilt's port, the URL tilt is made available on, as well as the [JWT](https://jwt.io) secret. The latter should be a randomly generated string, as this is used to authenticate users against tilt's API and an easily guessed secret might allow impersonating `root` and `moderator` accounts - -2. Mail settings: internally, tilt uses [nodemailer](https://nodemailer.com/) to send out emails. Therefore, configure an SMTP server and supply its TLS/SSL port, e.g., 465 - -3. Database settings: tilt needs to store data somewhere. Internally, we use TypeORM, which is database-agnostic, but we chose to use the [MySQL](https://www.mysql.com) / [MariaDB](https://mariadb.org) driver. While you could use some other database, these two are supported by default. Furthermore, you could use tools like [phpMyAdmin](https://www.phpmyadmin.net) to manage your database, but we don't recommend this in production. Please refer to the official documentation for whichever database you deploy - -```yaml -version: "3" - -services: - # the internet-facing proxy - proxy: - image: nginx/alpine - restart: always - ports: - - 80:80 - volumes: - # we'll configure nginx later. since you're probably - # already configuring a proxy yourself, we'll just show - # an example `server` block - - ./nginx.conf:/etc/nginx/conf.d/default.conf - - # tilt's persistence layer requires a mysql-like database - # we'll use mariadb here, but you can also use mysql - tilt_mariadb: - image: mariadb:latest - restart: always - volumes: - - ./tilt/mariadb:/var/lib/mysql - environment: - # tilt doesn't need root privileges on the database, - # therefore a regular user account and a random root - # password is the better choice - - MARIADB_RANDOM_ROOT_PASSWORD=yes - # these two don't need to be called "tilt", but this is - # a tilt example - - MARIADB_DATABASE=tilt - - MARIADB_USER=tilt - # genereate a password, as this one is shown publicly - # in this repository - - MARIADB_PASSWORD=this_is_not_secure_generate_something - - tilt: - image: hackaburg/tilt - restart: always - environment: - # the url under which you can reach tilt - - BASE_URL=https://your-hackathon.com/apply/ - # tilt's http port, which we need in nginx later. as - # tilt runs in a user account, it can't bind to port - # 80, therefore, you will still need some kind of - # proxy to expose tilt on port 80 - - PORT=3000 - - LOG_LEVEL=info - # generate a secure password for jwt tokens - - SECRET_JWT=this_is_not_secure_generate_a_password - # an smtp server - - MAIL_HOST=your-smtp.server - # tilt requires tls/ssl for mailing - - MAIL_PORT=465 - # your smtp username - - MAIL_USERNAME=your-email@domain.tld - # the smtp username's password - - MAIL_PASSWORD=password - # this section is similar to the mariadb configuration - # above. simply place username, database and password - # settings from above here - - DATABASE_NAME=tilt - - DATABASE_HOST=tilt_mariadb - - DATABASE_USERNAME=tilt - - DATABASE_PASSWORD=this_is_not_secure_generate_something - - DATABASE_PORT=3306 - - # to keep your containers up-to-date, we recommend using - # a service like watchtower, which continuously pulls for - # updates. see https://github.com/containrrr/watchtower for - # more information - watchtower: - image: containrrr/watchtower - restart: always - volumes: - - /var/run/docker.sock:/var/run/docker.sock -``` - -### NGINX - -With the environment configuration in place, we can configure NGINX to forward requests to tilt. This step is optional and you can technically expose tilt to the internet through the `PORT` environment variable. If you, like us, want to expose tilt through a subfolder URL, you can use a configuration similar to this: - -```nginx -server { - # the domain this server should listen to - server_name your-hackathon.com; - # the port we expose in the docker compose configuration above - listen 80; - - # we expect the entirety of the uri after the slash at tilt - # therefore we redirect /apply requests to /apply/ - location = /apply { - return 301 /apply/; - } - - # the actual forwarding block passing the requests to tilt - # watch the trailing slashes, or you might run into issues - location /apply/ { - # this forwards requests to the tilt container on port 3000, - # which we configured previously - proxy_pass http://tilt:3000/; - } -} -``` - -### Starting up - -Since database takes a brief moment for its setup, start with: - -```bash -$ docker compose up tilt_mariadb -``` - -Then wait for MariaDB to accept incoming connections. Once this is done, you can stop `docker-compose` and start up everything with: + + codecov + -```bash -$ docker compose up -``` + + David + -Depending on your setup, you might want to append the `-d` flag to run the containers in the background. + + GitHub license + +

-tilt should then be able to connect to the database and start up. If the database takes longer than tilt to start, tilt will restart because of the `restart: always` directive until it can connect successfully. +
-### Changing user groups - -With everything up and running, you usually want to configure tilt. For this, you need to register and change your user group. - -Since you have access to the server running tilt, you can spawn a shell in the tilt container and invoke the [usermod script](backend/src/usermod.ts). Please note that we're using `node:alpine` as a base image and therefore don't ship Bash. This script takes two arguments, the email of the user you want to change, as well as the group you want to assign to this user. To assign the `root` group to `you@example.com`, run: - -```bash -$ docker compose exec tilt sh -node@container:/app$ node backend/usermod.js you@example.com root -``` - -The logs will indicate success or failure and this process works similarly for `moderator`, or for demoting an account back to `user`. - -tilt's UI fetches the user role during the initial load. To see the settings, admission and statistics pages, you'll need to reload the page. - -### Environment variables - -tilt's backend can be configured through a set of environment variables. We try to keep this list up-to-date, but for the most recent set of variables check the [config-service.ts](backend/src/services/config-service.ts). All defaults and shown values are strings, but tilt parses them internally to, e.g., integers or booleans. - -#### App variables - -- `NODE_ENV` - in production mode, tilt reports all uncaught errors as "Internal error" - - value: `production` or `development` - - optional, default: `development` - -#### Database variables - -- `DATABASE_NAME` - the name of the database tilt should connect to - - value: string -- `DATABASE_HOST` - the host serving tilt's database - - value: hostname or IP address -- `DATABASE_PASSWORD` - the password for the database user - - value: string -- `DATABASE_PORT` - the port number of the database - - value: integer - - optional, default: `3306` -- `DATABASE_USERNAME` - the user for the database - - value: string - -#### HTTP variables - -- `BASE_URL` - the url under which tilt will be deployed, something in the sorts of `https://your-hackathon.com/apply` - - value: string -- `PORT` - the http port to listen on - - value: integer - - optional, default: `3000` - -#### Logging variables - -- `LOG_FILENAME` - tilt supports writing its log messages to files; supply a filename to persist log messages - - value: filename - - optional, default: `tilt.log` -- `LOG_LEVEL` - the level of logs tilt should output - - value: `debug`, `info` or `error` - - optional, default: `info` -- `LOG_SLACK_WEBHOOK_URL` - tilt supports sending errors to a Slack channel; supply an URL to message the configured channel - - value: Slack webhook URL - - optional - -#### Mail variables - -- `MAIL_HOST` - an SMTP server to use for mailing - - value: hostname -- `MAIL_PASSWORD` - the password for the SMTP account - - value: string -- `MAIL_PORT` - the port for the SMTP server; tilt requires SSL/TLS - - value: integer - - optional, default: `465` -- `MAIL_USERNAME` - the username for the SMTP account - - value: string - -#### Secrets variables - -- `SECRET_JWT` - a secret used to sign login tokens - - value: string - -#### Service variables - -- `ENABLE_HAVEIBEENPWNED_SERVICE` - enable or disable checking password reuse with [haveibeenpwned.com](https://haveibeenpwned.com) - - value: `true` or `false` - - optional, default: `true` - -## Contributing - -If you found a bug or have an idea for a feature, simply [submit an issue](https://github.com/hackaburg/tilt/issues/new) or a pull request. We use [TSLint](https://palantir.github.io/tslint/) and [Prettier](https://prettier.io) to ensure consistent code styles and we have a set of unit tests for the backend in place to prevent things from breaking too easily. Also, we currently use a [GitHub project](https://github.com/hackaburg/tilt/projects/1) for our roadmap. - -### Developing locally - -The tilt repository ships with a [docker-compose.yml](docker-compose.yml), which includes a sample setup with MariaDB, the test SMTP server [MailDev](https://github.com/maildev/maildev) and phpMyAdmin. To mimic the proxy'd setup, it also includes build instructions for a tilt container, as well as an NGINX container. You usually only need `db`, `phpmyadmin` and `maildev`, therefore it's sufficient to start them using: - -```bash -docker compose up db phpmyadmin maildev -``` - -For local development, the backend supports reading `.env` files. Refer to [`.env.example`](backend/.env.example) for such a configuration and match the ports from the Docker Compose configuration. - -```bash -cp backend/.env.example backend/.env -``` - -You can then start the backend using: - -```bash -yarn backend::start -``` - -As the frontend is built in modern React, we use [Webpack](https://webpack.js.org) and its devserver to develop. The frontend is available at localhost:8080. The backend runs on a different port, so we need to tell the frontend how to reach the backend. This can be done through the `API_BASE_URL` environment variable. To start the frontend devserver with the backend listening on port 3000, simply provide it using: - -```bash -API_BASE_URL=http://localhost:3000/api yarn frontend::start -``` - -After registering, open localhost:8082 to view the verification mail. - -We also provide a set of utility scripts in our [package.json](package.json)'s `script` section, such as linting, formatting and type-checking. - -### Building images +Like many other hackathons, we previously used [Quill](https://github.com/techx/quill) for our application process, which worked really well for us in the past. Especially Quill's process was a blessing: an application consists of two steps, the profile creation and, once an attendee was admitted to the event, the spot confirmation. We attended different events that used different processes and found this to be easy for both the attendees and organizers. -Our final image is built using the `backend::build` and `frontend::build` scripts and stripping development dependencies as well as source files from the final container. To allow arbitrary base urls with a statically built frontend, we transiently replace this url during container startup. Please refer to the [Dockerfile](Dockerfile) and [entrypoint.sh](entrypoint.sh) for more information. +Faced with maintaining our [fork](https://github.com/hackaburg/quill) with our set of changes to the application process, as well as maintaining an Angular.JS frontend and an untyped Express backend, we wanted to build a registration system ourselves, that matched our needs on a tech stack we're more familiar with. -## License +
-tilt is released under the [MIT License](LICENSE). +

+ +   + +

diff --git a/backend/src/controllers/application-controller.ts b/backend/src/controllers/application-controller.ts index 07695250..51819287 100644 --- a/backend/src/controllers/application-controller.ts +++ b/backend/src/controllers/application-controller.ts @@ -260,9 +260,10 @@ export class ApplicationController { @Authorized(UserRole.User) public async createTeam( @Body() { data: teamDTO }: { data: TeamRequestDTO }, + @CurrentUser() user: User, ): Promise { const team = convertBetweenEntityAndDTO(teamDTO, Team); - const createdTeam = await this._teams.createTeam(team); + const createdTeam = await this._teams.createTeam(team, user); return convertBetweenEntityAndDTO(createdTeam, TeamDTO); } @@ -314,6 +315,42 @@ export class ApplicationController { return response; } + /** + * Remove a user from a team. + * @param teamId The id of the team + * @param userId The id of the user + */ + @Delete("/team/:teamId/members/:userId") + @Authorized(UserRole.User) + public async removeUserFromTeam( + @Param("teamId") teamId: number, + @Param("userId") userId: number, + @CurrentUser() user: User, + ): Promise { + await this._teams.removeUserFromTeam(teamId, userId, user); + const response = new SuccessResponseDTO(); + response.success = true; + return response; + } + + /** + * Set the owner of a team + * @param teamId The id of the team + * @param userId The id of the new owner + */ + @Put("/team/:teamId/owner/:userId") + @Authorized(UserRole.User) + public async setOwner( + @Param("teamId") teamId: number, + @Param("userId") userId: number, + @CurrentUser() user: User, + ): Promise { + await this._teams.setOwner(teamId, userId, user); + const response = new SuccessResponseDTO(); + response.success = true; + return response; + } + /** * Get team by id. * @param id The id of the team diff --git a/backend/src/controllers/dto.ts b/backend/src/controllers/dto.ts index dad2d324..0732e693 100644 --- a/backend/src/controllers/dto.ts +++ b/backend/src/controllers/dto.ts @@ -407,6 +407,10 @@ export class UserDetailsRepsonseDTO { public email!: string; @Expose() public role!: UserRole; + @Expose() + public teamRequest!: TeamDTO | null; + @Expose() + public team!: TeamDTO | null; } export class UserDTO { @@ -445,12 +449,22 @@ export class UserDTO { public checkedIn!: boolean; @Expose() public profileSubmitted!: boolean; + @Expose() + @Type(() => TeamDTO) + @ValidateNested() + public teamRequest!: TeamDTO | null; + @Expose() + @Type(() => TeamDTO) + @ValidateNested() + public team!: TeamDTO | null; } export class UserTokenResponseDTO { @Expose() public token!: string; @Expose() + @Type(() => UserDTO) + @ValidateNested() public user!: UserDTO; } @@ -498,7 +512,18 @@ export class UserListDto { @Expose() public id!: number; @Expose() - public name!: string; + public firstName!: string; + @Expose() + public lastName!: string; +} + +export class UserResponseDto { + @Expose() + public id!: number; + @Expose() + public firstName!: string; + @Expose() + public lastName!: string; } export class ApplicationDTO { @@ -506,8 +531,6 @@ export class ApplicationDTO { @Type(() => UserDTO) public user!: UserDTO; @Expose() - public teams!: string[]; - @Expose() @Type(() => AnswerDTO) public answers!: AnswerDTO[]; } @@ -522,20 +545,22 @@ export class IDRequestDTO implements IApiRequest { public data!: number; } -export class UserResponseDto { - @Expose() - public id!: number; - @Expose() - public name!: string; -} - export class TeamDTO { @Expose() public id!: number; @Expose() public title!: string; @Expose() - public users?: string[]; + @Type(() => UserResponseDto) + public owner!: UserResponseDto; + @Expose() + @Type(() => UserResponseDto) + @ValidateNested() + public users!: UserResponseDto[]; + @Expose() + @Type(() => UserResponseDto) + @ValidateNested() + public requests!: UserResponseDto[]; @Expose() public teamImg!: string; @Expose() @@ -548,23 +573,24 @@ export class TeamResponseDTO { @Expose() public title!: string; @Expose() - @Type(() => UserResponseDto) - public users?: UserResponseDto[]; - @Expose() public teamImg!: string; @Expose() public description!: string; @Expose() @Type(() => UserResponseDto) - public requests?: UserResponseDto[]; + public owner!: UserResponseDto; + @Expose() + @Type(() => UserResponseDto) + public users!: UserResponseDto[]; + @Expose() + @Type(() => UserResponseDto) + public requests!: UserResponseDto[]; } export class TeamRequestDTO { @Expose() public title!: string; @Expose() - public users?: number[]; - @Expose() public teamImg!: string; @Expose() public description!: string; @@ -576,8 +602,6 @@ export class TeamUpdateDTO { @Expose() public title!: string; @Expose() - public users?: number[]; - @Expose() public teamImg!: string; @Expose() public description!: string; @@ -626,9 +650,9 @@ export class RatingDTO { @Expose() public readonly id!: number; @Expose() - @Type(() => UserDTO) + @Type(() => UserResponseDto) @ValidateNested() - public user!: UserDTO; + public user!: UserResponseDto; @Expose() @Type(() => ProjectDTO) @ValidateNested() diff --git a/backend/src/controllers/users-controller.ts b/backend/src/controllers/users-controller.ts index b423398f..8f04481e 100644 --- a/backend/src/controllers/users-controller.ts +++ b/backend/src/controllers/users-controller.ts @@ -148,9 +148,6 @@ export class UsersController { const response = new UserTokenResponseDTO(); response.token = this._users.generateLoginToken(user); response.user = convertBetweenEntityAndDTO(user, UserDTO); - const userDetails = await this._users.getUser(user.email); - response.user.firstName = userDetails.firstName; - response.user.lastName = userDetails.lastName; return response; } @@ -166,9 +163,6 @@ export class UsersController { const response = new UserTokenResponseDTO(); response.token = this._users.generateLoginToken(user); response.user = convertBetweenEntityAndDTO(user, UserDTO); - const userDetails = await this._users.getUser(user.email); - response.user.firstName = userDetails.firstName; - response.user.lastName = userDetails.lastName; return response; } diff --git a/backend/src/entities/project.ts b/backend/src/entities/project.ts index 80fd61a1..9ac7806d 100644 --- a/backend/src/entities/project.ts +++ b/backend/src/entities/project.ts @@ -12,7 +12,7 @@ import { Team } from "./team"; export class Project { @PrimaryGeneratedColumn() public readonly id!: number; - @ManyToOne(() => Team, { eager: true }) + @ManyToOne(() => Team, { eager: true, onDelete: "CASCADE" }) @JoinColumn() public team!: Team; @Column({ length: 1024 }) diff --git a/backend/src/entities/team.ts b/backend/src/entities/team.ts index 7dbcc318..d2e0fcda 100644 --- a/backend/src/entities/team.ts +++ b/backend/src/entities/team.ts @@ -1,5 +1,13 @@ -import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; +import { + Column, + Entity, + PrimaryGeneratedColumn, + OneToMany, + OneToOne, + JoinColumn, +} from "typeorm"; import { Longtext } from "./longtext"; +import { User } from "./user"; @Entity() export class Team { @@ -7,15 +15,42 @@ export class Team { public readonly id!: number; @Column({ length: 1024 }) public title!: string; - // TODO many-to-many instead of simple-array (array of userId strings) - @Column("simple-array") - public users!: string[]; // TODO rename teamImg to image @Column() public teamImg!: string; @Longtext() public description!: string; - // TODO many-to-many instead of simple-array (array of userId strings) - @Column("simple-array") - public requests!: string[]; + // The owner also has to have their user.team property set to this team. + // Beware that this is not eagerly loaded, because it will throw recursion depth + // errors due to user.team being eagerly loaded already. Add it to "relations" + // when doing database queries instead. + @OneToOne(() => User) + @JoinColumn() + public owner!: User; + @OneToMany(() => User, (user) => user.teamRequest) + public requests!: User[]; + @OneToMany(() => User, (user) => user.team) + public users!: User[]; + + /** + * List of user ids that are part of the team. + */ + public userIds(): number[] { + if (!this.users) { + return []; + } + + return this.users.map(({ id }) => id); + } + + /** + * List of user ids that requested to join the team. + */ + public requestUserIds(): number[] { + if (!this.requests) { + return []; + } + + return this.requests.map(({ id }) => id); + } } diff --git a/backend/src/entities/user.ts b/backend/src/entities/user.ts index c82c92a0..5c40e81f 100644 --- a/backend/src/entities/user.ts +++ b/backend/src/entities/user.ts @@ -4,8 +4,10 @@ import { Entity, PrimaryGeneratedColumn, UpdateDateColumn, + ManyToOne, } from "typeorm"; import { UserRole } from "./user-role"; +import { Team } from "./team"; @Entity() export class User { @@ -45,4 +47,16 @@ export class User { public declined!: boolean; @Column({ default: false }) public checkedIn!: boolean; + @ManyToOne(() => Team, (team) => team.requests, { + nullable: true, + eager: true, + onDelete: "SET NULL", + }) + public teamRequest: Team | null = null; + @ManyToOne(() => Team, (team) => team.users, { + nullable: true, + eager: true, + onDelete: "SET NULL", + }) + public team: Team | null = null; } diff --git a/backend/src/services/application-service.ts b/backend/src/services/application-service.ts index de296544..56ca9b26 100644 --- a/backend/src/services/application-service.ts +++ b/backend/src/services/application-service.ts @@ -18,7 +18,6 @@ import { } from "./question-service"; import { ISettingsService, SettingsServiceToken } from "./settings-service"; import { IUserService, UserServiceToken } from "./user-service"; -import { Team } from "../entities/team"; /** * A form containing questions and given answers. @@ -41,7 +40,6 @@ export interface IRawAnswer { */ export interface IApplication { user: User; - teams: string[]; answers: readonly Answer[]; } @@ -121,7 +119,6 @@ export const ApplicationServiceToken = new Token(); @Service(ApplicationServiceToken) export class ApplicationService implements IApplicationService { private _answers!: Repository; - private _teams!: Repository; constructor( @Inject(QuestionGraphServiceToken) @@ -138,7 +135,6 @@ export class ApplicationService implements IApplicationService { */ public async bootstrap(): Promise { this._answers = this._database.getRepository(Answer); - this._teams = this._database.getRepository(Team); } /** @@ -545,13 +541,8 @@ export class ApplicationService implements IApplicationService { } } - const allTeams = await this._teams.find(); - const applications = allUsers.map((user) => ({ answers: answersByUserID.get(user.id) ?? [], - teams: allTeams - .filter((team) => team.users.toString().includes(user.id.toString())) - .map((team) => team.title), user, })); diff --git a/backend/src/services/project-service.ts b/backend/src/services/project-service.ts index f964bada..8450fdc3 100644 --- a/backend/src/services/project-service.ts +++ b/backend/src/services/project-service.ts @@ -5,7 +5,6 @@ import { IService } from "."; import { DatabaseServiceToken, IDatabaseService } from "./database-service"; import { Project } from "../entities/project"; import { Settings } from "../entities/settings"; -import { Team } from "../entities/team"; import { User } from "../entities/user"; import { UserRole } from "../entities/user-role"; @@ -46,7 +45,6 @@ export const ProjectServiceToken = new Token(); @Service(ProjectServiceToken) export class ProjectService implements IProjectService { private _projects!: Repository; - private _teams!: Repository; private _settings!: Repository; public constructor( @@ -58,7 +56,6 @@ export class ProjectService implements IProjectService { */ public async bootstrap(): Promise { this._projects = this._database.getRepository(Project); - this._teams = this._database.getRepository(Team); this._settings = this._database.getRepository(Settings); } @@ -66,11 +63,6 @@ export class ProjectService implements IProjectService { * Gets all projects that the user may see. */ public async getAllProjects(user: User): Promise { - const teams = await this._teams.find(); - const teamIds = teams - .filter((team) => team.users.includes(user.id.toString())) - .map((team) => team.id); - const [settings] = await this._settings.find(); const allowRatingProjects = settings.project.allowRatingProjects; const isAdmin = user.role === UserRole.Root; @@ -80,7 +72,7 @@ export class ProjectService implements IProjectService { return ( isAdmin || (project.allowRating && allowRatingProjects) || - teamIds.includes(project.team.id) + project.team.id === user.team?.id ); }); } @@ -98,6 +90,7 @@ export class ProjectService implements IProjectService { await this.checkPermission(existing, user); + // Only allow updating these fields: existing.title = project.title; existing.description = project.description; existing.image = project.image; @@ -157,7 +150,7 @@ export class ProjectService implements IProjectService { } const team = project.team; - if (!team || !team.users.includes(user.id.toString())) { + if (!team || user.team?.id !== team.id) { // Tried to access a project belonging to a different team, forbidden throw new NotFoundError(); } diff --git a/backend/src/services/rating-service.ts b/backend/src/services/rating-service.ts index 0ec8927a..298efbcb 100644 --- a/backend/src/services/rating-service.ts +++ b/backend/src/services/rating-service.ts @@ -7,6 +7,7 @@ import { ISettingsService, SettingsServiceToken } from "./settings-service"; import { Rating } from "../entities/rating"; import { RatingDTO, + ProjectDTO, ProjectRatingResultDTO, convertBetweenEntityAndDTO, } from "../controllers/dto"; @@ -82,7 +83,6 @@ export class RatingService implements IRatingService { projectId: number, user: User, ): Promise { - // TODO test return this._database.getRepository(Rating).find({ where: { project: { @@ -219,7 +219,7 @@ export class RatingService implements IRatingService { }); result.push({ - project, + project: convertBetweenEntityAndDTO(project, ProjectDTO), averagesPerCriterion, }); } @@ -235,6 +235,21 @@ export class RatingService implements IRatingService { throw new ForbiddenError("Only admitted users may rate projects"); } + // TODO only users in a team are allowed to vote. Prevent them from + // leaving their team to vote for themselves. However, they could leave + // their team and create a new one. So they need to be in a team that has + // been created before the rating started. I don't track the timestamp yet... + // Prevent teams from changing once rating starts is the best bet. Use the + // existing switch and prevent changes, add some text to the settings page that + // this is the case to avoid confusion. Still, people could join a second team + // before voting starts, in order to vote for their own project. I don't think + // it is possible to avoid this situation. It needs to be prohibited via the + // rules and it needs a way to check if this happened, but the ratings are + // anonymous. HOORAY. Or we allow people to rate their own project after all. + if (user.team == null) { + throw new ForbiddenError("You need to be part of a team to vote"); + } + const settings = await this._settings.getSettings(); if (!settings.project.allowRatingProjects) { throw new ForbiddenError("Rating is not allowed due to settings"); @@ -248,11 +263,16 @@ export class RatingService implements IRatingService { throw new ForbiddenError("Rating this project is not allowed"); } - const team = await this._teams.findOneBy({ id: project.team.id }); + const team = await this._teams.findOne({ + where: { + id: project.team.id, + }, + relations: ["users", "requests"], + }); if (!team) { throw new NotFoundError("Team not found"); } - if (team.users.includes(user.id.toString())) { + if (team.userIds().includes(user.id)) { throw new ForbiddenError("You can't rate your own project"); } diff --git a/backend/src/services/team-service.ts b/backend/src/services/team-service.ts index 568a4738..687dea8c 100644 --- a/backend/src/services/team-service.ts +++ b/backend/src/services/team-service.ts @@ -1,9 +1,11 @@ +import { NotFoundError } from "routing-controllers"; import { Inject, Service, Token } from "typedi"; import { Repository } from "typeorm"; import { IService } from "."; import { DatabaseServiceToken, IDatabaseService } from "./database-service"; import { Team } from "../entities/team"; import { Project } from "../entities/project"; +import { UserRole } from "../entities/user-role"; import { TeamResponseDTO, convertBetweenEntityAndDTO, @@ -21,7 +23,7 @@ export interface ITeamService extends IService { /** * Create new team */ - createTeam(team: Team): Promise; + createTeam(team: Team, user: User): Promise; /** * Update team */ @@ -36,16 +38,28 @@ export interface ITeamService extends IService { acceptUserToTeam( teamId: number, userId: number, - currentUserId: User, + requestedBy: User, + ): Promise; + /** + * Remove user from team + */ + removeUserFromTeam( + teamId: number, + userId: number, + requestedBy: User, ): Promise; /** * Delete single team by id */ - deleteTeamByID(id: number, currentUserId: User): Promise; + deleteTeamByID(id: number, currentUser: User): Promise; /** * Request to join a team */ requestToJoinTeam(teamId: number, user: User): Promise; + /** + * Set the owner of a team + */ + setOwner(teamId: number, userId: number, requestedBy: User): Promise; } /** @@ -79,7 +93,9 @@ export class TeamService implements ITeamService { * Gets all teams. */ public async getAllTeams(): Promise { - return this._database.getRepository(Team).find(); + return this._database.getRepository(Team).find({ + relations: ["users", "requests", "owner"], + }); } /** @@ -95,32 +111,39 @@ export class TeamService implements ITeamService { throw new Error("Team description cannot be empty"); } - if (team.users.length === 0) { - throw new Error("Please add at least one user to the team"); + const originalTeam = await this._teams.findOne({ + where: { id: team.id }, + relations: ["users", "requests", "owner"], + }); + + if (!originalTeam) { + throw new NotFoundError(); } - const originTeam = await this._teams.findOneBy({ id: team.id }); - const originTeamUsers = originTeam?.users.map((id) => id.toString()); + const originalTeamUserIds = originalTeam?.userIds(); - if (!originTeamUsers!.includes(user.id.toString())) { + // TODO test that admins and members can change the title, but not othe users + if ( + user.role !== UserRole.Root && + !originalTeamUserIds!.includes(user.id) + ) { throw new Error("You are not a member of this team"); } - if (originTeam?.users.join() !== team.users.join()) { - if (originTeam!.users[0].toString() !== user.id.toString()) { - throw new Error("You are not the owner of this team"); - } - return this._teams.save(team); - } - - return this._teams.save(team); + return this._teams.save({ + ...originalTeam, + ...team, + // Use the dedicated http endpoint to change ownership + owner: originalTeam.owner, + }); } /** * Creates a team. * @param team The team to create + * @param user The user who wants to create a team */ - public async createTeam(team: Team): Promise { + public async createTeam(team: Team, user: User): Promise { const placeholder_img = [ "https://i.imgur.com/CWwOYnr.png", "https://i.imgur.com/ZpFOtqy.png", @@ -144,47 +167,45 @@ export class TeamService implements ITeamService { throw new Error("Team description cannot be empty"); } - if (team.users.length === 0) { - throw new Error("Please add at least one user to the team"); - } + // TODO leaving team should make someone else owner + // TODO order of team.users not guaranteed anymore I guess, + // - you can only own one team, just like you can only be part of one team - const maxUsers = 8; - if (team.users.length > maxUsers) { - throw new Error(`A team can have a maximum of ${maxUsers} users`); + if (user.team) { + throw new Error("You are already part of a team"); } - const userId = team.users[0]; - const allTeams = await this._database.getRepository(Team).find(); - const userTeams = allTeams.filter( - (t) => t.users[0].toString() === userId.toString(), - ); - - if (userTeams.length >= 5) { - throw new Error( - "You already have created 5 teams. Please delete one first.", - ); + if (team.teamImg === "") { + const randomIndex = Math.floor(Math.random() * placeholder_img.length); + team.teamImg = placeholder_img[randomIndex]; } - try { - if (team.teamImg === "") { - team.teamImg = - placeholder_img[Math.floor(Math.random() * placeholder_img.length)]; - } - team.requests = []; - const createdTeam = await this._teams.save(team); - - // Every team gets one project by default - const project = new Project(); - project.title = `${team.title}'s Project`; - project.description = ""; - project.team = createdTeam; - project.allowRating = false; - await this._projects.save(project); - - return createdTeam; - } catch (e) { - throw e; - } + team.owner = user; + + const createdTeam = await this._teams.save(team); + + user.team = createdTeam; + await this._users.save(user); + + // Every team gets one project by default + const project = new Project(); + project.title = `${team.title}'s Project`; + project.description = ""; + project.team = createdTeam; + project.allowRating = false; + await this._projects.save(project); + + return { + ...createdTeam, + owner: { + // Ensure there is no circular reference. Use TeamResponseDTO and its + // UserResponseDto (team.owner.team.owner.team...) Otherwise + // convertBetweenEntityAndDTO will fail with an recursion depth error + id: createdTeam.owner.id, + firstName: createdTeam.owner.firstName, + lastName: createdTeam.owner.lastName, + }, + }; } /** @@ -192,38 +213,16 @@ export class TeamService implements ITeamService { * @param id The id of the team */ public async getTeamByID(id: number): Promise { - const team = await this._teams.findOneBy({ id }); + const team = await this._teams.findOne({ + where: { id }, + relations: ["users", "requests", "owner"], + }); if (team == null) { return undefined; - } else { - const teamResponse = convertBetweenEntityAndDTO(team, TeamResponseDTO); - const users = await this._users.findByIds(team?.users!); - const mappedUsers: any = []; - - teamResponse.users!.forEach((userId) => { - users.forEach((user) => { - if (user.id.toString() === userId.toString()) { - mappedUsers.push({ - id: user.id, - name: `${user.firstName} ${user.lastName[0]}. #${user.id}`, - }); - } - }); - }); - - teamResponse.users = mappedUsers; - - const userRequests = await this._users.findByIds(team?.requests!); - teamResponse.requests = userRequests.map((user) => { - return { - id: user.id, - name: `${user.firstName} ${user.lastName[0]}. #${user.id}`, - }; - }); - - return teamResponse; } + + return convertBetweenEntityAndDTO(team, TeamResponseDTO); } /** @@ -232,25 +231,28 @@ export class TeamService implements ITeamService { * @param user The user requesting to join */ public async requestToJoinTeam(teamId: number, user: User): Promise { - const team = await this._teams.findOneBy({ id: teamId }); + const team = await this._teams.findOne({ + where: { id: teamId }, + relations: ["users", "requests", "owner"], + }); if (team == null) { throw new Error(`no team with id ${teamId}`); } - // TODO team.users and team.requests are string arrays, instead of - // arrays of user entities. Write tests, then use a many-to-many relationship - // instead and don't use toString anymore. - const requests = team.requests.map((id) => id.toString()); + const requests = team.requestUserIds(); - if (requests.indexOf(user.id.toString()) > -1) { + if (requests.includes(user.id)) { throw new Error( `user ${user.id} already requested to join team ${teamId}`, ); } - team.requests.push(user.id.toString()); - await this._teams.save(team); + await this._users.save({ + ...user, + teamRequest: team, + }); + return Promise.resolve(); } @@ -258,10 +260,16 @@ export class TeamService implements ITeamService { * Deletes a team by its id. * @param id The id of the team */ - public async deleteTeamByID(id: number, currentUserId: User): Promise { - const team = await this._teams.findOneBy({ id }); - - if (team?.users[0].toString() !== currentUserId.id.toString()) { + public async deleteTeamByID(id: number, currentUser: User): Promise { + const team = await this._teams.findOne({ + where: { id }, + relations: ["users", "requests", "owner"], + }); + + if ( + currentUser.role !== UserRole.Root && + team?.owner?.id !== currentUser.id + ) { throw new Error("You are not the owner of this team"); } @@ -279,29 +287,103 @@ export class TeamService implements ITeamService { public async acceptUserToTeam( teamId: number, userId: number, - user: User, + requestedBy: User, ): Promise { - const team = await this._teams.findOneBy({ id: teamId }); + const team = await this._teams.findOne({ + where: { id: teamId }, + relations: ["users", "requests", "owner"], + }); if (team == null) { throw new Error(`no team with id ${teamId}`); } - if (team?.users[0].toString() !== user.id.toString()) { + const isAdmin = requestedBy.role === UserRole.Root; + const isOwner = team.owner?.id === requestedBy.id; + if (!isAdmin && !isOwner) { throw new Error("You are not the owner of this team"); } - const requests = team.requests.map((id) => id.toString()); - - if (requests.indexOf(userId.toString()) === -1) { + if (!team.requestUserIds().includes(userId)) { throw new Error(`user ${userId} did not request to join team ${teamId}`); } - team.requests = team.requests.filter( - (id) => id.toString() !== userId.toString(), - ); - team.users.push(userId.toString()); - await this._teams.save(team); + await this._users.update({ id: userId }, { team, teamRequest: null }); + + return Promise.resolve(); + } + + /** + * Remove a user from a team + */ + public async removeUserFromTeam( + teamId: number, + userId: number, + requestedBy: User, + ): Promise { + const team = await this._teams.findOne({ + where: { id: teamId }, + relations: ["users", "requests", "owner"], + }); + + if (team == null) { + throw new Error(`no team with id ${teamId}`); + } + + const isOwner = team.owner?.id === requestedBy.id; + const isAdmin = requestedBy.role === UserRole.Root; + if (!isOwner && !isAdmin && userId !== requestedBy.id) { + throw new Error("Only the owner may remove other users from a team"); + } + + if (team.owner?.id === userId) { + throw new Error("Make someone else owner of the team first"); + } + + if (!team.userIds().includes(userId)) { + throw new Error(`user ${userId} is not part of the team ${teamId}`); + } + + await this._users.update({ id: userId }, { team: null, teamRequest: null }); + + return Promise.resolve(); + } + + /** + * Set the owner of a team + */ + public async setOwner( + teamId: number, + userId: number, + requestedBy: User, + ): Promise { + const team = await this._teams.findOne({ + where: { id: teamId }, + relations: ["users", "requests", "owner"], + }); + + if (team == null) { + throw new Error(`No team with id ${teamId}`); + } + + const isAdmin = requestedBy.role === UserRole.Root; + const isOwner = team.owner?.id === requestedBy.id; + if (!isAdmin && !isOwner) { + throw new Error("Only the owner may change the owner"); + } + + if (!team.userIds().includes(userId)) { + throw new Error(`User ${userId} is not part of the team ${teamId}`); + } + + const newOwner = await this._users.findOneBy({ id: userId }); + + if (newOwner == null) { + throw new Error(`User ${userId} not found`); + } + + await this._teams.update({ id: teamId }, { owner: newOwner }); + return Promise.resolve(); } } diff --git a/backend/src/services/user-service.ts b/backend/src/services/user-service.ts index 7115e45b..521ce414 100644 --- a/backend/src/services/user-service.ts +++ b/backend/src/services/user-service.ts @@ -18,7 +18,7 @@ import { IHaveibeenpwnedService, PasswordReuseError, } from "./haveibeenpwned-service"; -import { UserDetailsRepsonseDTO, UserListDto } from "../controllers/dto"; +import { UserListDto } from "../controllers/dto"; /** * An interface describing user handling. @@ -51,7 +51,7 @@ export interface IUserService extends IService { /** * Gets a user by their mail. */ - getUser(userEmail: string): Promise; + getUser(userEmail: string): Promise; /** * Get all users only name and id @@ -247,20 +247,12 @@ export class UserService implements IUserService { /** * Get user details */ - public async getUser(userEmail: string): Promise { - const user = await this._users.findOneOrFail({ + public async getUser(userEmail: string): Promise { + return await this._users.findOneOrFail({ where: { email: userEmail, }, }); - - const response = new UserDetailsRepsonseDTO(); - response.email = user.email; - response.firstName = user.firstName; - response.lastName = user.lastName; - response.role = user.role; - - return response; } /** @@ -274,7 +266,8 @@ export class UserService implements IUserService { return users.map((user) => { const userDto = new UserListDto(); userDto.id = user.id; - userDto.name = `${user.firstName} ${user.lastName[0]}. #${user.id}`; + userDto.firstName = user.firstName; + userDto.lastName = user.lastName; return userDto; }); } diff --git a/backend/test/controllers/application-controller.spec.ts b/backend/test/controllers/application-controller.spec.ts new file mode 100644 index 00000000..3793492f --- /dev/null +++ b/backend/test/controllers/application-controller.spec.ts @@ -0,0 +1,72 @@ +import { classToPlain } from "class-transformer"; +import { ApplicationController } from "../../src/controllers/application-controller"; +import { Team } from "../../src/entities/team"; +import { User } from "../../src/entities/user"; +import { UserRole } from "../../src/entities/user-role"; +import { IApplicationService } from "../../src/services/application-service"; +import { ITeamService } from "../../src/services/team-service"; +import { IUserService } from "../../src/services/user-service"; +import { MockedService } from "../services/mock"; +import { MockApplicationService } from "../services/mock/mock-application-service"; +import { MockTeamsService } from "../services/mock/mock-teams-service"; +import { MockUserService } from "../services/mock/mock-user-service"; + +describe("ApplicationController", () => { + let applicationService: MockedService; + let userService: MockedService; + let teamService: MockedService; + let controller: ApplicationController; + + beforeEach(() => { + applicationService = new MockApplicationService(); + userService = new MockUserService(); + teamService = new MockTeamsService(); + controller = new ApplicationController( + applicationService.instance, + userService.instance, + teamService.instance, + ); + }); + + describe("getAllTeams", () => { + it("does not expose member email addresses", async () => { + expect.assertions(2); + + const member = Object.assign(new User(), { + id: 1, + firstName: "Jane", + lastName: "Doe", + email: "jane@example.com", + role: UserRole.User, + password: "", + verifyToken: "", + tokenSecret: "", + forgotPasswordToken: "", + team: null, + teamRequest: null, + }); + + const mockTeam = Object.assign(new Team(), { + id: 1, + title: "Test Team", + teamImg: "", + description: "A team", + owner: member, + users: [member], + requests: [], + }); + + teamService.mocks.getAllTeams.mockResolvedValue([mockTeam]); + + const teams = await controller.getAllTeams(); + + // Simulate the ResponseInterceptor which serializes with excludeAll + const serialized = classToPlain(teams, { + strategy: "excludeAll", + }) as any[]; + + expect(serialized).toHaveLength(1); + expect(serialized[0].users[0]).not.toHaveProperty("email"); + }); + }); +}); diff --git a/backend/test/controllers/auth.spec.ts b/backend/test/controllers/auth.spec.ts new file mode 100644 index 00000000..45a07ef6 --- /dev/null +++ b/backend/test/controllers/auth.spec.ts @@ -0,0 +1,304 @@ +import { hash } from "bcrypt"; +import * as http from "http"; +import * as dotenv from "dotenv"; +import * as path from "path"; + +import { createExpressServer, useContainer } from "routing-controllers"; +import { RatingController } from "../../src/controllers/rating-controller"; +import { UsersController } from "../../src/controllers/users-controller"; +import { User } from "../../src/entities/user"; +import { UserRole } from "../../src/entities/user-role"; +import { HttpService } from "../../src/services/http-service"; +import { IRatingService } from "../../src/services/rating-service"; +import { TokenService, ITokenService } from "../../src/services/token-service"; +import { + ConfigurationService, + IConfigurationService, +} from "../../src/services/config-service"; +import { UserService, IUserService } from "../../src/services/user-service"; +import { IApplicationService } from "../../src/services/application-service"; +import { MockedService } from "../services/mock"; +import { MockRatingService } from "../services/mock/mock-rating-service"; +import { MockApplicationService } from "../services/mock/mock-application-service"; +import { MockEmailTemplateService } from "../services/mock/mock-email-template-service"; +import { MockLoggerService } from "../services/mock/mock-logger-service"; +import { MockHaveibeenpwnedService } from "../services/mock/mock-haveibeenpwned-service"; +import { Repository } from "typeorm"; +import { TestDatabaseService } from "../services/mock/mock-database-service"; +import { ResponseInterceptor } from "../../src/interceptors/response-interceptor"; + +/* + * These tests just check that UserRoles and stuff work as expected. + * And that the backend can actually receive http requests + */ +describe("Auth", () => { + let database: TestDatabaseService; + let ratingService: MockedService; + let applicationService: MockedService; + // An actual userService that talks to the in-memory test database + let userService: IUserService; + let configurationService: IConfigurationService; + let tokenService: ITokenService; + + beforeEach(() => { + ratingService = new MockRatingService(); + }); + + let server: http.Server; + let port: number; + let baseUrl: string; + + let userRepo: Repository; + + let rootUser: User; + let regularUser: User; + + let rootToken: string; + let regularUserToken: string; + + const password = "test1234"; + + // const tokenMap: Record = { + // "root-token": rootUser, + // "user-token": regularUser, + // }; + + // const emailMap: Record = { + // [rootUser.email]: rootUser, + // [regularUser.email]: regularUser, + // }; + + beforeAll(async () => { + rootUser = Object.assign(new User(), { + id: 1, + role: UserRole.Root, + createdAt: new Date("2026-01-01"), + updatedAt: new Date("2026-04-12"), + firstName: "rootFirst", + lastName: "rootLast", + email: "root@root.root", + password: await hash(password, 10), + tokenSecret: "root_secret_token_key_abc123", + verifyToken: "", + forgotPasswordToken: "forgot_password_token_def456", + initialProfileFormSubmittedAt: new Date("2026-02-01"), + confirmationExpiresAt: new Date("2026-05-01"), + profileSubmitted: true, + admitted: true, + confirmed: true, + declined: false, + checkedIn: true, + }); + + regularUser = Object.assign(new User(), { + id: 2, + role: UserRole.User, + createdAt: new Date("2026-01-01"), + updatedAt: new Date("2026-04-12"), + firstName: "regularFirst", + lastName: "regularLast", + email: "regular@regular.regular", + password: await hash(password, 10), + tokenSecret: "regular_secret_token_key_abc123", + verifyToken: "", + forgotPasswordToken: "forgot_password_token_def456", + initialProfileFormSubmittedAt: new Date("2026-02-01"), + confirmationExpiresAt: new Date("2026-05-01"), + profileSubmitted: true, + admitted: true, + confirmed: true, + declined: false, + checkedIn: true, + }); + + dotenv.config({ path: path.resolve(__dirname, "../../.env.example") }); + + database = new TestDatabaseService(); + await database.bootstrap(); + + userRepo = database.getRepository(User); + await userRepo.save([rootUser, regularUser]); + + ratingService = new MockRatingService(); + applicationService = new MockApplicationService(); + configurationService = new ConfigurationService(); + tokenService = new TokenService(configurationService); + userService = new UserService( + new MockHaveibeenpwnedService().instance, + database, + new MockLoggerService().instance, + tokenService, + new MockEmailTemplateService().instance, + ); + + await tokenService.bootstrap(); + await configurationService.bootstrap(); + await userService.bootstrap(); + + rootToken = tokenService.sign({ secret: rootUser.tokenSecret }); + regularUserToken = tokenService.sign({ secret: regularUser.tokenSecret }); + + // jest + // .spyOn(userService, 'findUserWithCredentials') + // .mockImplementation(async (email: string, password: string): Promise => { + // return emailMap[email] + // }); + + const httpService = new HttpService(null as any, null as any, userService); + + useContainer({ + get(target: Function) { + if (target === RatingController) { + return new RatingController(ratingService.instance); + } + if (target === UsersController) { + return new UsersController(userService, applicationService.instance); + } + return new (target as any)(); + }, + } as any); + + const app = createExpressServer({ + controllers: [RatingController, UsersController], + routePrefix: "/api", + currentUserChecker: (action) => httpService.getCurrentUser(action), + authorizationChecker: (action, roles) => + httpService.isActionAuthorized(action, roles), + interceptors: [ResponseInterceptor], + }); + + server = http.createServer(app); + await new Promise((resolve) => server.listen(0, () => resolve())); + port = (server.address() as any).port; + baseUrl = `http://localhost:${port}`; + }); + + afterAll(async () => { + await new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())), + ); + }); + + it("rejects unauthenticated requests with 403", async () => { + expect.assertions(1); + + const response = await fetch(`${baseUrl}/api/ratings/rate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ data: {} }), + }); + + expect(response.status).toBe(403); + }); + + it("allows requests from User-role users", async () => { + expect.assertions(2); + + ratingService.mocks.upsertRating.mockResolvedValue({} as any); + + const response = await fetch(`${baseUrl}/api/ratings/rate`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${regularUserToken}`, + }, + body: JSON.stringify({ + data: { rating: 3, project: { id: 1 }, criterion: { id: 2 } }, + }), + }); + + expect(response.status).toBe(200); + expect(ratingService.mocks.upsertRating).toHaveBeenCalledWith( + expect.objectContaining({ + project: expect.objectContaining({ id: 1 }), + user: expect.objectContaining({ id: regularUser.id }), + criterion: expect.objectContaining({ id: 2 }), + }), + expect.objectContaining({ + id: regularUser.id, + }), + ); + }); + + it("passes authorization for admin (Root) users", async () => { + expect.assertions(1); + + ratingService.mocks.upsertRating.mockResolvedValue({} as any); + + const response = await fetch(`${baseUrl}/api/ratings/rate`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${rootToken}`, + }, + body: JSON.stringify({ data: {} }), + }); + + // Authorization passed; any status other than 403 is acceptable here + expect(response.status).not.toBe(403); + }); + + describe("user controller", () => { + it("logs in and does not expose sensitive data", async () => { + const response = await fetch(`${baseUrl}/api/user/login`, { + method: "POST", + body: JSON.stringify({ + data: { email: regularUser.email, password }, + }), + headers: { "Content-Type": "application/json" }, + }); + const { data } = await response.json(); + expect(data.user).toHaveProperty("id"); + expect(data.user).toHaveProperty("firstName"); + expect(data.user).toHaveProperty("lastName"); + expect(data.user).not.toHaveProperty("password"); + expect(data.user).not.toHaveProperty("tokenSecret"); + expect(data.user).not.toHaveProperty("verifyToken"); + expect(data.user).not.toHaveProperty("forgotPasswordToken"); + }); + + it("/refreshtoken should not expose sensitive data", async () => { + const response = await fetch(`${baseUrl}/api/user/refreshtoken`, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${rootToken}`, + }, + }); + const { data } = await response.json(); + expect(data.user).toHaveProperty("id"); + expect(data.user).toHaveProperty("firstName"); + expect(data.user).toHaveProperty("lastName"); + expect(data.user).not.toHaveProperty("password"); + expect(data.user).not.toHaveProperty("tokenSecret"); + expect(data.user).not.toHaveProperty("verifyToken"); + expect(data.user).not.toHaveProperty("forgotPasswordToken"); + }); + + it("/list should not expose sensitive data", async () => { + const response = await fetch(`${baseUrl}/api/user/list`, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${rootToken}`, + }, + }); + const { data } = await response.json(); + + expect(data.length).toEqual(2); + const ids = data.map((user: any) => user.id); + expect(ids).toContain(rootUser.id); + expect(ids).toContain(regularUser.id); + + for (const user of data) { + expect(user).toHaveProperty("id"); + expect(user).toHaveProperty("firstName"); + expect(user).toHaveProperty("lastName"); + expect(user).not.toHaveProperty("password"); + expect(user).not.toHaveProperty("tokenSecret"); + expect(user).not.toHaveProperty("verifyToken"); + expect(user).not.toHaveProperty("forgotPasswordToken"); + } + }); + }); +}); diff --git a/backend/test/controllers/rating-controller.spec.ts b/backend/test/controllers/rating-controller.spec.ts deleted file mode 100644 index 3ce6b9e0..00000000 --- a/backend/test/controllers/rating-controller.spec.ts +++ /dev/null @@ -1,210 +0,0 @@ -import * as http from "http"; -import { createExpressServer, useContainer } from "routing-controllers"; -import { validate } from "class-validator"; -import { plainToClass } from "class-transformer"; -import { RatingController } from "../../src/controllers/rating-controller"; -import { RatingDTO } from "../../src/controllers/dto"; -import { Rating } from "../../src/entities/rating"; -import { User } from "../../src/entities/user"; -import { UserRole } from "../../src/entities/user-role"; -import { HttpService } from "../../src/services/http-service"; -import { IRatingService } from "../../src/services/rating-service"; -import { IUserService } from "../../src/services/user-service"; -import { MockedService } from "../services/mock"; -import { MockRatingService } from "../services/mock/mock-rating-service"; -import { MockUserService } from "../services/mock/mock-user-service"; - -describe("RatingController", () => { - let ratingService: MockedService; - let userService: MockedService; - let controller: RatingController; - - beforeEach(() => { - ratingService = new MockRatingService(); - controller = new RatingController(ratingService.instance); - }); - - it("creates a rating and delegates to the rating service", async () => { - expect.assertions(2); - - const ratingDTO = new RatingDTO(); - (ratingDTO as any).rating = 3; - - const user = new User(); - const createdRating = new Rating(); - (createdRating as any).id = 1; - - ratingService.mocks.upsertRating.mockResolvedValue(createdRating); - - const result = await controller.rate({ data: ratingDTO }, user); - - expect(ratingService.mocks.upsertRating).toBeCalled(); - expect(result).toBeDefined(); - }); - - describe("authorization", () => { - let server: http.Server; - let port: number; - - const rootUser = Object.assign(new User(), { id: 1, role: UserRole.Root }); - const regularUser = Object.assign(new User(), { - id: 2, - role: UserRole.User, - }); - - const tokenMap: Record = { - "root-token": rootUser, - "user-token": regularUser, - }; - - beforeAll(async () => { - ratingService = new MockRatingService(); - userService = new MockUserService(); - - userService.mocks.findUserByLoginToken.mockImplementation( - async (token: string) => tokenMap[token] ?? null, - ); - - const httpService = new HttpService( - null as any, - null as any, - userService.instance, - ); - - useContainer({ - get(target: Function) { - if (target === RatingController) { - return new RatingController(ratingService.instance); - } - return new (target as any)(); - }, - } as any); - - const app = createExpressServer({ - controllers: [RatingController], - routePrefix: "/api", - currentUserChecker: (action) => httpService.getCurrentUser(action), - authorizationChecker: (action, roles) => - httpService.isActionAuthorized(action, roles), - }); - - server = http.createServer(app); - await new Promise((resolve) => server.listen(0, () => resolve())); - port = (server.address() as any).port; - }); - - afterAll(async () => { - await new Promise((resolve, reject) => - server.close((err) => (err ? reject(err) : resolve())), - ); - }); - - it("rejects unauthenticated requests with 403", async () => { - expect.assertions(1); - - const response = await fetch( - `http://localhost:${port}/api/ratings/rate`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ data: {} }), - }, - ); - - expect(response.status).toBe(403); - }); - - it("allows requests from User-role users", async () => { - expect.assertions(2); - - ratingService.mocks.upsertRating.mockResolvedValue({} as any); - - const response = await fetch( - `http://localhost:${port}/api/ratings/rate`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer user-token", - }, - body: JSON.stringify({ - data: { rating: 3, project: { id: 1 }, criterion: { id: 2 } }, - }), - }, - ); - - expect(response.status).toBe(200); - expect(ratingService.mocks.upsertRating).toHaveBeenCalledWith( - expect.objectContaining({ - project: expect.objectContaining({ id: 1 }), - user: expect.objectContaining({ id: regularUser.id }), - criterion: expect.objectContaining({ id: 2 }), - }), - regularUser, - ); - }); - - it("passes authorization for admin (Root) users", async () => { - expect.assertions(1); - - ratingService.mocks.upsertRating.mockResolvedValue({} as any); - - const response = await fetch( - `http://localhost:${port}/api/ratings/rate`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer root-token", - }, - body: JSON.stringify({ data: {} }), - }, - ); - - // Authorization passed; any status other than 403 is acceptable here - expect(response.status).not.toBe(403); - }); - }); - - describe("rating value validation", () => { - it("rejects a rating of 0", async () => { - expect.assertions(1); - - const dto = plainToClass(RatingDTO, { rating: 0 }); - const errors = await validate(dto, { skipMissingProperties: true }); - const ratingErrors = errors.filter((e) => e.property === "rating"); - - expect(ratingErrors.length).toBeGreaterThan(0); - }); - - it("rejects a rating of 6", async () => { - expect.assertions(1); - - const dto = plainToClass(RatingDTO, { rating: 6 }); - const errors = await validate(dto, { skipMissingProperties: true }); - const ratingErrors = errors.filter((e) => e.property === "rating"); - - expect(ratingErrors.length).toBeGreaterThan(0); - }); - - it("accepts a rating of 1", async () => { - expect.assertions(1); - - const dto = plainToClass(RatingDTO, { rating: 1 }); - const errors = await validate(dto, { skipMissingProperties: true }); - const ratingErrors = errors.filter((e) => e.property === "rating"); - - expect(ratingErrors).toHaveLength(0); - }); - - it("accepts a rating of 5", async () => { - expect.assertions(1); - - const dto = plainToClass(RatingDTO, { rating: 5 }); - const errors = await validate(dto, { skipMissingProperties: true }); - const ratingErrors = errors.filter((e) => e.property === "rating"); - - expect(ratingErrors).toHaveLength(0); - }); - }); -}); diff --git a/backend/test/services/mock/mock-teams-service.ts b/backend/test/services/mock/mock-teams-service.ts index 68e47fd6..c84213c0 100644 --- a/backend/test/services/mock/mock-teams-service.ts +++ b/backend/test/services/mock/mock-teams-service.ts @@ -15,5 +15,7 @@ export const MockTeamsService = jest.fn( requestToJoinTeam: jest.fn(), updateTeam: jest.fn(), acceptUserToTeam: jest.fn(), + removeUserFromTeam: jest.fn(), + setOwner: jest.fn(), }), ); diff --git a/backend/test/services/project-service-get-all-projects.spec.ts b/backend/test/services/project-service-get-all-projects.spec.ts index a1a57a87..8ff24108 100644 --- a/backend/test/services/project-service-get-all-projects.spec.ts +++ b/backend/test/services/project-service-get-all-projects.spec.ts @@ -88,17 +88,13 @@ describe(ProjectService.name, () => { // Create teams with projects const team1 = new Team(); team1.title = "Team 1"; - team1.users = [regularUser.id.toString()]; team1.teamImg = ""; team1.description = ""; - team1.requests = []; const team2 = new Team(); team2.title = "Team 2"; - team2.users = [regularUser.id.toString()]; team2.teamImg = ""; team2.description = ""; - team2.requests = []; const teamRepo = database.getRepository(Team); const savedTeams = await teamRepo.save([team1, team2]); @@ -139,10 +135,8 @@ describe(ProjectService.name, () => { // Create a team and project that the regular user is not part of const team = new Team(); team.title = "Other Team"; - team.users = []; // Regular user is not part of this team team.teamImg = ""; team.description = ""; - team.requests = []; const teamRepo = database.getRepository(Team); const savedTeam = await teamRepo.save(team); @@ -161,55 +155,42 @@ describe(ProjectService.name, () => { expect(allProjects).toHaveLength(0); }); - it("regular user, part of two teams, gets 3 projects", async () => { - expect.assertions(4); + it("regular user in a team with 2 projects gets 2 projects", async () => { + expect.assertions(3); // Create two teams with the regular user const team1 = new Team(); team1.title = "Team 1"; - team1.users = [regularUser.id.toString()]; team1.teamImg = ""; team1.description = ""; - team1.requests = []; - - const team2 = new Team(); - team2.title = "Team 2"; - team2.users = [regularUser.id.toString()]; - team2.teamImg = ""; - team2.description = ""; - team2.requests = []; const teamRepo = database.getRepository(Team); - const savedTeams = await teamRepo.save([team1, team2]); + const savedTeam = await teamRepo.save(team1); + + regularUser.team = savedTeam; + const userRepo = database.getRepository(User); + await userRepo.save(regularUser); - // Create 2 projects for team1 and 1 project for team2 const project1 = new Project(); - project1.team = savedTeams[0]; + project1.team = savedTeam; project1.title = "Project 1 - Team 1"; project1.description = "Description 1"; project1.allowRating = true; const project2 = new Project(); - project2.team = savedTeams[0]; + project2.team = savedTeam; project2.title = "Project 2 - Team 1"; project2.description = "Description 2"; project2.allowRating = false; - const project3 = new Project(); - project3.team = savedTeams[1]; - project3.title = "Project 1 - Team 2"; - project3.description = "Description 3"; - project3.allowRating = true; - const projectRepo = database.getRepository(Project); - await projectRepo.save([project1, project2, project3]); + await projectRepo.save([project1, project2]); const allProjects = await service.getAllProjects(regularUser); - expect(allProjects).toHaveLength(3); + expect(allProjects).toHaveLength(2); expect(allProjects[0]).toEqual(project1); expect(allProjects[1]).toEqual(project2); - expect(allProjects[2]).toEqual(project3); }); it("regular user without team gets projects that can be rated", async () => { @@ -220,10 +201,8 @@ describe(ProjectService.name, () => { // Create a team with no users const team = new Team(); team.title = "Public Team"; - team.users = []; team.teamImg = ""; team.description = ""; - team.requests = []; const teamRepo = database.getRepository(Team); const savedTeam = await teamRepo.save(team); diff --git a/backend/test/services/project-service.spec.ts b/backend/test/services/project-service.spec.ts index 4ba5aca6..f8a72c44 100644 --- a/backend/test/services/project-service.spec.ts +++ b/backend/test/services/project-service.spec.ts @@ -36,7 +36,13 @@ describe("ProjectService", () => { teamRepo = database.getRepository(Team); projectRepo = database.getRepository(Project); - // Create admin user + mockTeam = new Team(); + mockTeam.title = "Team 1"; + mockTeam.teamImg = ""; + mockTeam.description = ""; + mockTeam.requests = []; + mockTeam = await teamRepo.save(mockTeam); + adminUser = new User(); adminUser.firstName = "Admin"; adminUser.lastName = "User"; @@ -47,7 +53,6 @@ describe("ProjectService", () => { adminUser.tokenSecret = ""; adminUser.forgotPasswordToken = ""; - // Create regular users regularUser = new User(); regularUser.firstName = "Regular"; regularUser.lastName = "User"; @@ -57,6 +62,8 @@ describe("ProjectService", () => { regularUser.verifyToken = ""; regularUser.tokenSecret = ""; regularUser.forgotPasswordToken = ""; + regularUser.team = mockTeam; + regularUser.teamRequest = null; userWithoutTeam = new User(); userWithoutTeam.firstName = "Regular 2"; @@ -67,6 +74,8 @@ describe("ProjectService", () => { userWithoutTeam.verifyToken = ""; userWithoutTeam.tokenSecret = ""; userWithoutTeam.forgotPasswordToken = ""; + userWithoutTeam.team = null; + userWithoutTeam.teamRequest = null; [adminUser, regularUser, userWithoutTeam] = await userRepo.save([ adminUser, @@ -74,14 +83,6 @@ describe("ProjectService", () => { userWithoutTeam, ]); - mockTeam = new Team(); - mockTeam.title = "Team 1"; - mockTeam.users = [regularUser.id.toString()]; - mockTeam.teamImg = ""; - mockTeam.description = ""; - mockTeam.requests = []; - mockTeam = await teamRepo.save(mockTeam); - mockProject = new Project(); mockProject.team = mockTeam; mockProject.title = "Original Title"; diff --git a/backend/test/services/rating-service.spec.ts b/backend/test/services/rating-service.spec.ts index 1c10411c..2b1ba59e 100644 --- a/backend/test/services/rating-service.spec.ts +++ b/backend/test/services/rating-service.spec.ts @@ -14,6 +14,9 @@ import { ISettingsService } from "../../src/services/settings-service"; import { MockedService } from "./mock"; import { MockSettingsService } from "./mock/mock-settings-service"; import { TestDatabaseService } from "./mock/mock-database-service"; +import { validate } from "class-validator"; +import { plainToClass } from "class-transformer"; +import { RatingDTO } from "../../src/controllers/dto"; describe("RatingService", () => { let database: TestDatabaseService; @@ -29,6 +32,7 @@ describe("RatingService", () => { let ratingUser: User; let teamMember: User; let mockTeam: Team; + let mockTeam2: Team; let mockProject: Project; let mockCriterion: Criterion; @@ -48,6 +52,18 @@ describe("RatingService", () => { criterionRepo = database.getRepository(Criterion); ratingRepo = database.getRepository(Rating); + mockTeam = new Team(); + mockTeam.title = "Test Team"; + mockTeam.teamImg = ""; + mockTeam.description = ""; + mockTeam = await teamRepo.save(mockTeam); + + mockTeam2 = new Team(); + mockTeam2.title = "Test Team 2"; + mockTeam2.teamImg = ""; + mockTeam2.description = ""; + mockTeam2 = await teamRepo.save(mockTeam2); + // A user who will submit ratings (not in the project's team) ratingUser = new User(); ratingUser.firstName = "Rater"; @@ -59,6 +75,7 @@ describe("RatingService", () => { ratingUser.tokenSecret = ""; ratingUser.forgotPasswordToken = ""; ratingUser.admitted = true; + ratingUser.team = mockTeam2; // A user who is a member of the project team teamMember = new User(); @@ -71,17 +88,11 @@ describe("RatingService", () => { teamMember.tokenSecret = ""; teamMember.forgotPasswordToken = ""; teamMember.admitted = true; + teamMember.team = mockTeam; + teamMember.teamRequest = null; [ratingUser, teamMember] = await userRepo.save([ratingUser, teamMember]); - mockTeam = new Team(); - mockTeam.title = "Test Team"; - mockTeam.users = [teamMember.id.toString()]; - mockTeam.teamImg = ""; - mockTeam.description = ""; - mockTeam.requests = []; - mockTeam = await teamRepo.save(mockTeam); - mockProject = new Project(); mockProject.team = mockTeam; mockProject.title = "Test Project"; @@ -98,6 +109,95 @@ describe("RatingService", () => { await ratingService.bootstrap(); }); + describe("getUsersRatingsForProject", () => { + it("returns ratings for the specified project and user", async () => { + expect.assertions(2); + + settingsService.mocks.getSettings.mockResolvedValue({ + project: { allowRatingProjects: true }, + } as any); + + const rating = Object.assign(new Rating(), { + project: mockProject, + user: ratingUser, + criterion: mockCriterion, + rating: 4, + }); + await ratingService.upsertRating(rating, ratingUser); + + const results = await ratingService.getUsersRatingsForProject( + mockProject.id, + ratingUser, + ); + + expect(results).toHaveLength(1); + expect(results[0].rating).toBe(4); + }); + + it("returns an empty list when no ratings exist for the project", async () => { + expect.assertions(1); + + const results = await ratingService.getUsersRatingsForProject( + mockProject.id, + ratingUser, + ); + + expect(results).toHaveLength(0); + }); + + it("does not return ratings belonging to other users", async () => { + expect.assertions(1); + + await ratingRepo.save( + Object.assign(new Rating(), { + project: mockProject, + user: teamMember, + criterion: mockCriterion, + rating: 3, + }), + ); + + const results = await ratingService.getUsersRatingsForProject( + mockProject.id, + ratingUser, + ); + + expect(results).toHaveLength(0); + }); + + it("does not return ratings for other projects", async () => { + expect.assertions(1); + + settingsService.mocks.getSettings.mockResolvedValue({ + project: { allowRatingProjects: true }, + } as any); + + const otherProject = await projectRepo.save( + Object.assign(new Project(), { + team: mockTeam, + title: "Other Project", + description: "", + allowRating: true, + }), + ); + + const rating = Object.assign(new Rating(), { + project: otherProject, + user: ratingUser, + criterion: mockCriterion, + rating: 3, + }); + await ratingService.upsertRating(rating, ratingUser); + + const results = await ratingService.getUsersRatingsForProject( + mockProject.id, + ratingUser, + ); + + expect(results).toHaveLength(0); + }); + }); + describe("checkPermission", () => { describe("via upsertRating", () => { it("throws ForbiddenError if user is not admitted", async () => { @@ -364,4 +464,46 @@ describe("RatingService", () => { expect(resultB.averagesPerCriterion[0].average).toEqual(4); }); }); + + describe("rating value validation", () => { + it("rejects a rating of 0", async () => { + expect.assertions(1); + + const dto = plainToClass(RatingDTO, { rating: 0 }); + const errors = await validate(dto, { skipMissingProperties: true }); + const ratingErrors = errors.filter((e) => e.property === "rating"); + + expect(ratingErrors.length).toBeGreaterThan(0); + }); + + it("rejects a rating of 6", async () => { + expect.assertions(1); + + const dto = plainToClass(RatingDTO, { rating: 6 }); + const errors = await validate(dto, { skipMissingProperties: true }); + const ratingErrors = errors.filter((e) => e.property === "rating"); + + expect(ratingErrors.length).toBeGreaterThan(0); + }); + + it("accepts a rating of 1", async () => { + expect.assertions(1); + + const dto = plainToClass(RatingDTO, { rating: 1 }); + const errors = await validate(dto, { skipMissingProperties: true }); + const ratingErrors = errors.filter((e) => e.property === "rating"); + + expect(ratingErrors).toHaveLength(0); + }); + + it("accepts a rating of 5", async () => { + expect.assertions(1); + + const dto = plainToClass(RatingDTO, { rating: 5 }); + const errors = await validate(dto, { skipMissingProperties: true }); + const ratingErrors = errors.filter((e) => e.property === "rating"); + + expect(ratingErrors).toHaveLength(0); + }); + }); }); diff --git a/backend/test/services/team-service.spec.ts b/backend/test/services/team-service.spec.ts index fde43186..114a9dc3 100644 --- a/backend/test/services/team-service.spec.ts +++ b/backend/test/services/team-service.spec.ts @@ -1,3 +1,4 @@ +import { Repository } from "typeorm"; import { Project } from "../../src/entities/project"; import { Team } from "../../src/entities/team"; import { User } from "../../src/entities/user"; @@ -8,6 +9,31 @@ import { UserRole } from "../../src/entities/user-role"; describe("TeamService", () => { let teamService: ITeamService; let database: TestDatabaseService; + let userRepo: Repository; + let teamRepo: Repository; + + const makeUser = (email: string, role = UserRole.User): User => { + const user = new User(); + user.firstName = "Test"; + user.lastName = "User"; + user.email = email; + user.password = ""; + user.role = role; + user.verifyToken = ""; + user.tokenSecret = ""; + user.forgotPasswordToken = ""; + user.team = null; + user.teamRequest = null; + return user; + }; + + const makeTeam = (title = "Test Team"): Team => { + const team = new Team(); + team.title = title; + team.teamImg = ""; + team.description = "A test team"; + return team; + }; beforeAll(async () => { database = new TestDatabaseService(); @@ -18,37 +44,231 @@ describe("TeamService", () => { await database.nuke(); teamService = new TeamService(database); await teamService.bootstrap(); + userRepo = database.getRepository(User); + teamRepo = database.getRepository(Team); }); describe("createTeam", () => { it("creates a default project", async () => { const projectRepo = database.getRepository(Project); - const userRepo = database.getRepository(User); expect(await projectRepo.count()).toEqual(0); - const user = new User(); - user.firstName = "Regular"; - user.lastName = "User"; - user.email = "user@test.com"; - user.password = ""; - user.role = UserRole.User; - user.verifyToken = ""; - user.tokenSecret = ""; - user.forgotPasswordToken = ""; - await userRepo.save(user); - - const team = new Team(); - team.title = "Team 1"; - team.users = [user.id.toString()]; - team.teamImg = ""; - team.description = "Team 1 description"; - team.requests = []; - - await teamService.createTeam(team); + const user = await userRepo.save(makeUser("user@test.com")); + + const team = makeTeam("Team 1"); + await teamService.createTeam(team, user); const projects = await projectRepo.find(); expect(projects).toHaveLength(1); expect(projects[0].team.title).toEqual(team.title); }); + + it("sets the creator as the team owner", async () => { + expect.assertions(1); + + const user = await userRepo.save(makeUser("owner@test.com")); + const createdTeam = await teamService.createTeam(makeTeam(), user); + + const foundTeam = await teamRepo.findOne({ + where: { id: createdTeam.id }, + relations: ["owner"], + }); + + expect(foundTeam!.owner.id).toEqual(user.id); + }); + + it("assigns the newly created team to the user", async () => { + expect.assertions(1); + + const user = await userRepo.save(makeUser("member@test.com")); + const createdTeam = await teamService.createTeam(makeTeam(), user); + + const updatedUser = await userRepo.findOne({ where: { id: user.id } }); + expect(updatedUser!.team!.id).toEqual(createdTeam.id); + }); + }); + + describe("acceptUserToTeam", () => { + it("throws when the requester is neither owner nor admin", async () => { + expect.assertions(1); + + const owner = await userRepo.save(makeUser("owner@test.com")); + const requestingUser = await userRepo.save(makeUser("req@test.com")); + const randomUser = await userRepo.save(makeUser("random@test.com")); + + const createdTeam = await teamService.createTeam(makeTeam(), owner); + + await userRepo.save({ ...requestingUser, teamRequest: createdTeam }); + + await expect( + teamService.acceptUserToTeam( + createdTeam.id, + requestingUser.id, + randomUser, + ), + ).rejects.toThrow("You are not the owner of this team"); + }); + + it("allows the team owner to accept a join request", async () => { + expect.assertions(2); + + const owner = await userRepo.save(makeUser("owner@test.com")); + const requestingUser = await userRepo.save(makeUser("req@test.com")); + + const createdTeam = await teamService.createTeam(makeTeam(), owner); + await userRepo.save({ ...requestingUser, teamRequest: createdTeam }); + + await teamService.acceptUserToTeam( + createdTeam.id, + requestingUser.id, + owner, + ); + + const acceptedUser = await userRepo.findOne({ + where: { id: requestingUser.id }, + }); + expect(acceptedUser!.team!.id).toEqual(createdTeam.id); + expect(acceptedUser!.teamRequest).toBeNull(); + }); + + it("allows an admin to accept a join request", async () => { + expect.assertions(2); + + const owner = await userRepo.save(makeUser("owner@test.com")); + const admin = await userRepo.save( + makeUser("admin@test.com", UserRole.Root), + ); + const requestingUser = await userRepo.save(makeUser("req@test.com")); + + const createdTeam = await teamService.createTeam(makeTeam(), owner); + await userRepo.save({ ...requestingUser, teamRequest: createdTeam }); + + await teamService.acceptUserToTeam( + createdTeam.id, + requestingUser.id, + admin, + ); + + const acceptedUser = await userRepo.findOne({ + where: { id: requestingUser.id }, + }); + expect(acceptedUser!.team!.id).toEqual(createdTeam.id); + expect(acceptedUser!.teamRequest).toBeNull(); + }); + }); + + describe("removeUserFromTeam", () => { + it("allows a user to remove themselves from a team", async () => { + expect.assertions(1); + + const owner = await userRepo.save(makeUser("owner@test.com")); + const member = await userRepo.save(makeUser("member@test.com")); + + const createdTeam = await teamService.createTeam(makeTeam(), owner); + await userRepo.save({ ...member, team: createdTeam }); + + await teamService.removeUserFromTeam(createdTeam.id, member.id, member); + + const updatedMember = await userRepo.findOne({ + where: { id: member.id }, + }); + expect(updatedMember!.team).toBeNull(); + }); + + it("throws when trying to remove the team owner", async () => { + expect.assertions(1); + + const owner = await userRepo.save(makeUser("owner@test.com")); + const createdTeam = await teamService.createTeam(makeTeam(), owner); + + await expect( + teamService.removeUserFromTeam(createdTeam.id, owner.id, owner), + ).rejects.toThrow("Make someone else owner of the team first"); + }); + + it("removes a member from the team successfully", async () => { + expect.assertions(1); + + const owner = await userRepo.save(makeUser("owner@test.com")); + const member = await userRepo.save(makeUser("member@test.com")); + + const createdTeam = await teamService.createTeam(makeTeam(), owner); + await userRepo.save({ ...member, team: createdTeam }); + + await teamService.removeUserFromTeam(createdTeam.id, member.id, owner); + + const updatedMember = await userRepo.findOne({ + where: { id: member.id }, + }); + expect(updatedMember!.team).toBeNull(); + }); + + it("throws when trying to remove users from other teams", async () => { + expect.assertions(1); + + const t1Owner = await userRepo.save(makeUser("t1Owner@test.com")); + const t1Member = await userRepo.save(makeUser("t1Member@test.com")); + const t1 = await teamService.createTeam(makeTeam(), t1Owner); + await userRepo.save({ ...t1Member, team: t1 }); + + const t2Owner = await userRepo.save(makeUser("t2Owner@test.com")); + const t2Member = await userRepo.save(makeUser("t2Member@test.com")); + const t2 = await teamService.createTeam(makeTeam(), t2Owner); + await userRepo.save({ ...t2Member, team: t2 }); + + await expect( + teamService.removeUserFromTeam(t1.id, t1Member.id, t2Owner), + ).rejects.toThrow("Only the owner may remove other users from a team"); + }); + }); + + describe("setOwner", () => { + it("throws when the requester is neither owner nor admin", async () => { + expect.assertions(1); + + const owner = await userRepo.save(makeUser("owner@test.com")); + const member = await userRepo.save(makeUser("member@test.com")); + const randomUser = await userRepo.save(makeUser("random@test.com")); + + const createdTeam = await teamService.createTeam(makeTeam(), owner); + await userRepo.save({ ...member, team: createdTeam }); + + await expect( + teamService.setOwner(createdTeam.id, member.id, randomUser), + ).rejects.toThrow("Only the owner may change the owner"); + }); + + it("throws when the target user is not in the team", async () => { + expect.assertions(1); + + const owner = await userRepo.save(makeUser("owner@test.com")); + const outsider = await userRepo.save(makeUser("outsider@test.com")); + + const createdTeam = await teamService.createTeam(makeTeam(), owner); + + await expect( + teamService.setOwner(createdTeam.id, outsider.id, owner), + ).rejects.toThrow( + `User ${outsider.id} is not part of the team ${createdTeam.id}`, + ); + }); + + it("sets the new owner successfully", async () => { + expect.assertions(1); + + const owner = await userRepo.save(makeUser("owner@test.com")); + const member = await userRepo.save(makeUser("member@test.com")); + + const createdTeam = await teamService.createTeam(makeTeam(), owner); + await userRepo.save({ ...member, team: createdTeam }); + + await teamService.setOwner(createdTeam.id, member.id, owner); + + const updatedTeam = await teamRepo.findOne({ + where: { id: createdTeam.id }, + relations: ["owner"], + }); + expect(updatedTeam!.owner.id).toEqual(member.id); + }); }); }); diff --git a/quickstart.md b/docs/docker-development.md similarity index 96% rename from quickstart.md rename to docs/docker-development.md index a284850a..85d0470c 100644 --- a/quickstart.md +++ b/docs/docker-development.md @@ -37,6 +37,7 @@ docker exec backend yarn run backend::test ## Lint ```sh +docker exec backend yarn audit --groups dependencies docker exec backend node ./node_modules/.bin/prettier --config .prettierrc.js '{backend,frontend}/{src,test}/**/*.{ts,tsx}' --write docker exec backend yarn run lint docker exec frontend yarn run frontend::typecheck diff --git a/docs/docs.md b/docs/docs.md new file mode 100644 index 00000000..2ed9f90e --- /dev/null +++ b/docs/docs.md @@ -0,0 +1,292 @@ +# Docs + +## How does tilt work? + +Similar to Quill, tilt's application process consists of the profile creation and a confirmation form. tilt, among other differences, employs a role-based model: + +- An attendee is a `user` who can fill out the forms +- A `moderator` can see applications, statistics and admit users +- `root` can modify tilt's settings + +`root` can do whatever a `moderator` can do, and a `moderator` can do whatever a `user` can do. This means each user group needs to register through the same form first and you can adjust a user's group later. During registration, tilt queries [haveibeenpwned](https://haveibeenpwned.com) to check for password breaches. + +After the setup, `root` can configure tilt's appearance, as well as the application process: + +- When should the profile form be made available? +- Until when can users submit their profile form? +- How long can users confirm their spots? + +The former two points are represented as dates, whereas the latter is represented in hours. When a user is admitted, they'll have `n` hours to confirm their spot. After this deadline passed, their application will show up as "expired". + +Each application consists of two forms, which `root` can configure. Questions have a title, a Markdown description and a type. tilt currently supports text, number, choices and country questions, which each have different configuration options. Each question can however have a parent question, which allows you to build complex, tree-like questionnaires dynamically. + +Admitting users is done through the admission page, which consists of a table and a search bar. You can search for any info an attendee might've provided and also filter for special flags such as `is:admitted` or `is:expired`. + +To admit someone, check the box on the left for as many people as you want and hit "admit". tilt will send them an email, which `root` can configure as well. The top-most checkbox depends on the visible checkboxes below. Clicking it will either select or deselect all visible rows, leaving your not shown selection intact. + +When you add a question to the profile form because you need to ask, e.g., for new terms and conditions and a user already filled out this form, there's almost no chance they'd open up their application and answer this question without you explicitly telling them to. + +Disregarding which type of question you want to add, users need to confirm their spot using the second form anyways. tilt adds all new profile questions a user cannot have seen initially to the confirmation form. This way, users need to answer these questions at last during the confirmation step and if they don't agree with, e.g., newly added terms, their spot will expire. + +## Usage + +tilt was built to be deploy-once-and-forget. For this reason, we provide a Docker image `hackaburg/tilt`. While you could run tilt natively, we highly recommend a containerized setup, as you can update it more easily. + +At Hackaburg, we run our server setup through a central NGINX proxy facing the internet and routing requests to individual containers. Given you might want to access tilt through an url like `https://your-hackathon.com/apply`, we'll show both the NGINX setup, as well as a Docker Compose configuration supporting such a setup. + +### Docker Compose + +tilt uses environment variables for configuration. There are three sections you need to configure: + +1. General settings: things like tilt's port, the URL tilt is made available on, as well as the [JWT](https://jwt.io) secret. The latter should be a randomly generated string, as this is used to authenticate users against tilt's API and an easily guessed secret might allow impersonating `root` and `moderator` accounts + +2. Mail settings: internally, tilt uses [nodemailer](https://nodemailer.com/) to send out emails. Therefore, configure an SMTP server and supply its TLS/SSL port, e.g., 465 + +3. Database settings: tilt needs to store data somewhere. Internally, we use TypeORM, which is database-agnostic, but we chose to use the [MySQL](https://www.mysql.com) / [MariaDB](https://mariadb.org) driver. While you could use some other database, these two are supported by default. Furthermore, you could use tools like [phpMyAdmin](https://www.phpmyadmin.net) to manage your database, but we don't recommend this in production. Please refer to the official documentation for whichever database you deploy + +```yaml +version: "3" + +services: + # the internet-facing proxy + proxy: + image: nginx/alpine + restart: always + ports: + - 80:80 + volumes: + # we'll configure nginx later. since you're probably + # already configuring a proxy yourself, we'll just show + # an example `server` block + - ./nginx.conf:/etc/nginx/conf.d/default.conf + + # tilt's persistence layer requires a mysql-like database + # we'll use mariadb here, but you can also use mysql + tilt_mariadb: + image: mariadb:latest + restart: always + volumes: + - ./tilt/mariadb:/var/lib/mysql + environment: + # tilt doesn't need root privileges on the database, + # therefore a regular user account and a random root + # password is the better choice + - MARIADB_RANDOM_ROOT_PASSWORD=yes + # these two don't need to be called "tilt", but this is + # a tilt example + - MARIADB_DATABASE=tilt + - MARIADB_USER=tilt + # generate a password, as this one is shown publicly + # in this repository + - MARIADB_PASSWORD=this_is_not_secure_generate_something + + tilt: + image: hackaburg/tilt + restart: always + environment: + # the url under which you can reach tilt + - BASE_URL=https://your-hackathon.com/apply/ + # tilt's http port, which we need in nginx later. as + # tilt runs in a user account, it can't bind to port + # 80, therefore, you will still need some kind of + # proxy to expose tilt on port 80 + - PORT=3000 + - LOG_LEVEL=info + # generate a secure password for jwt tokens + - SECRET_JWT=this_is_not_secure_generate_a_password + # an smtp server + - MAIL_HOST=your-smtp.server + # tilt requires tls/ssl for mailing + - MAIL_PORT=465 + # your smtp username + - MAIL_USERNAME=your-email@domain.tld + # the smtp username's password + - MAIL_PASSWORD=password + # this section is similar to the mariadb configuration + # above. simply place username, database and password + # settings from above here + - DATABASE_NAME=tilt + - DATABASE_HOST=tilt_mariadb + - DATABASE_USERNAME=tilt + - DATABASE_PASSWORD=this_is_not_secure_generate_something + - DATABASE_PORT=3306 + + # to keep your containers up-to-date, we recommend using + # a service like watchtower, which continuously pulls for + # updates. see https://github.com/containrrr/watchtower for + # more information + watchtower: + image: containrrr/watchtower + restart: always + volumes: + - /var/run/docker.sock:/var/run/docker.sock +``` + +### NGINX + +With the environment configuration in place, we can configure NGINX to forward requests to tilt. This step is optional and you can technically expose tilt to the internet through the `PORT` environment variable. If you, like us, want to expose tilt through a subfolder URL, you can use a configuration similar to this: + +```nginx +server { + # the domain this server should listen to + server_name your-hackathon.com; + # the port we expose in the docker compose configuration above + listen 80; + + # we expect the entirety of the uri after the slash at tilt + # therefore we redirect /apply requests to /apply/ + location = /apply { + return 301 /apply/; + } + + # the actual forwarding block passing the requests to tilt + # watch the trailing slashes, or you might run into issues + location /apply/ { + # this forwards requests to the tilt container on port 3000, + # which we configured previously + proxy_pass http://tilt:3000/; + } +} +``` + +### Starting up + +Since database takes a brief moment for its setup, start with: + +```bash +$ docker compose up tilt_mariadb +``` + +Then wait for MariaDB to accept incoming connections. Once this is done, you can stop `docker-compose` and start up everything with: + +```bash +$ docker compose up +``` + +Depending on your setup, you might want to append the `-d` flag to run the containers in the background. + +tilt should then be able to connect to the database and start up. If the database takes longer than tilt to start, tilt will restart because of the `restart: always` directive until it can connect successfully. + +### Changing user groups + +With everything up and running, you usually want to configure tilt. For this, you need to register and change your user group. + +Since you have access to the server running tilt, you can spawn a shell in the tilt container and invoke the [usermod script](backend/src/usermod.ts). Please note that we're using `node:alpine` as a base image and therefore don't ship Bash. This script takes two arguments, the email of the user you want to change, as well as the group you want to assign to this user. To assign the `root` group to `you@example.com`, run: + +```bash +$ docker compose exec tilt sh +node@container:/app$ node backend/usermod.js you@example.com root +``` + +The logs will indicate success or failure and this process works similarly for `moderator`, or for demoting an account back to `user`. + +tilt's UI fetches the user role during the initial load. To see the settings, admission and statistics pages, you'll need to reload the page. + +### Environment variables + +tilt's backend can be configured through a set of environment variables. We try to keep this list up-to-date, but for the most recent set of variables check the [config-service.ts](backend/src/services/config-service.ts). All defaults and shown values are strings, but tilt parses them internally to, e.g., integers or booleans. + +#### App variables + +- `NODE_ENV` - in production mode, tilt reports all uncaught errors as "Internal error" + - value: `production` or `development` + - optional, default: `development` + +#### Database variables + +- `DATABASE_NAME` - the name of the database tilt should connect to + - value: string +- `DATABASE_HOST` - the host serving tilt's database + - value: hostname or IP address +- `DATABASE_PASSWORD` - the password for the database user + - value: string +- `DATABASE_PORT` - the port number of the database + - value: integer + - optional, default: `3306` +- `DATABASE_USERNAME` - the user for the database + - value: string + +#### HTTP variables + +- `BASE_URL` - the url under which tilt will be deployed, something in the sorts of `https://your-hackathon.com/apply` + - value: string +- `PORT` - the http port to listen on + - value: integer + - optional, default: `3000` + +#### Logging variables + +- `LOG_FILENAME` - tilt supports writing its log messages to files; supply a filename to persist log messages + - value: filename + - optional, default: `tilt.log` +- `LOG_LEVEL` - the level of logs tilt should output + - value: `debug`, `info` or `error` + - optional, default: `info` +- `LOG_SLACK_WEBHOOK_URL` - tilt supports sending errors to a Slack channel; supply an URL to message the configured channel + - value: Slack webhook URL + - optional + +#### Mail variables + +- `MAIL_HOST` - an SMTP server to use for mailing + - value: hostname +- `MAIL_PASSWORD` - the password for the SMTP account + - value: string +- `MAIL_PORT` - the port for the SMTP server; tilt requires SSL/TLS + - value: integer + - optional, default: `465` +- `MAIL_USERNAME` - the username for the SMTP account + - value: string + +#### Secrets variables + +- `SECRET_JWT` - a secret used to sign login tokens + - value: string + +#### Service variables + +- `ENABLE_HAVEIBEENPWNED_SERVICE` - enable or disable checking password reuse with [haveibeenpwned.com](https://haveibeenpwned.com) + - value: `true` or `false` + - optional, default: `true` + +## Contributing + +If you found a bug or have an idea for a feature, simply [submit an issue](https://github.com/hackaburg/tilt/issues/new) or a pull request. We use [TSLint](https://palantir.github.io/tslint/) and [Prettier](https://prettier.io) to ensure consistent code styles and we have a set of unit tests for the backend in place to prevent things from breaking too easily. Also, we currently use a [GitHub project](https://github.com/hackaburg/tilt/projects/1) for our roadmap. + +### Developing locally + +The tilt repository ships with a [docker-compose.yml](docker-compose.yml), which includes a sample setup with MariaDB, the test SMTP server [MailDev](https://github.com/maildev/maildev) and phpMyAdmin. To mimic the proxy'd setup, it also includes build instructions for a tilt container, as well as an NGINX container. You usually only need `db`, `phpmyadmin` and `maildev`, therefore it's sufficient to start them using: + +```bash +docker compose up db phpmyadmin maildev +``` + +For local development, the backend supports reading `.env` files. Refer to [`.env.example`](backend/.env.example) for such a configuration and match the ports from the Docker Compose configuration. + +```bash +cp backend/.env.example backend/.env +``` + +You can then start the backend using: + +```bash +yarn backend::start +``` + +As the frontend is built in modern React, we use [Webpack](https://webpack.js.org) and its devserver to develop. The frontend is available at localhost:8080. The backend runs on a different port, so we need to tell the frontend how to reach the backend. This can be done through the `API_BASE_URL` environment variable. To start the frontend devserver with the backend listening on port 3000, simply provide it using: + +```bash +API_BASE_URL=http://localhost:3000/api yarn frontend::start +``` + +After registering, open localhost:8082 to view the verification mail. + +We also provide a set of utility scripts in our [package.json](package.json)'s `script` section, such as linting, formatting and type-checking. + +### Building images + +Our final image is built using the `backend::build` and `frontend::build` scripts and stripping development dependencies as well as source files from the final container. To allow arbitrary base urls with a statically built frontend, we transiently replace this url during container startup. Please refer to the [Dockerfile](Dockerfile) and [entrypoint.sh](entrypoint.sh) for more information. + +## License + +tilt is released under the [MIT License](LICENSE). diff --git a/docs/screenshot-1.jpg b/docs/screenshot-1.jpg new file mode 100644 index 00000000..4dbe0e8c Binary files /dev/null and b/docs/screenshot-1.jpg differ diff --git a/docs/screenshot-2.jpg b/docs/screenshot-2.jpg new file mode 100644 index 00000000..2d7624ef Binary files /dev/null and b/docs/screenshot-2.jpg differ diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index ea07a051..72782c10 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -29,6 +29,7 @@ import type { SuccessResponseDTO, TeamDTO, TeamResponseDTO, + TeamUpdateDTO, UserDTO, UserListDto, } from "./types/dto"; @@ -253,13 +254,11 @@ export class ApiClient { title: string, description: string, teamImg: string, - users: number[], - ): Promise { - await this.post( + ): Promise { + return await this.post( "/application/team", { title, - users, teamImg, description, }, @@ -268,28 +267,12 @@ export class ApiClient { /** * Update new team - * @param id The team's id - * @param title The team's title - * @param description The team's description - * @param teamImg The team's image - * @param users The team's users + * @param team containing id, title, description and teamImg */ - public async updateTeam( - id: number, - title: string, - description: string, - teamImg: string, - users: number[], - ): Promise { + public async updateTeam(team: TeamUpdateDTO): Promise { await this.put( "/application/team", - { - id, - title, - users, - teamImg, - description, - }, + team, ); } @@ -317,6 +300,32 @@ export class ApiClient { ); } + /** + * Remove a user from a team. + * @param teamId The team's id + * @param userId The user's id + */ + public async removeUserFromTeam( + teamId: number, + userId: number, + ): Promise { + await this.delete( + `/application/team/${teamId}/members/${userId}`, + ); + } + + /** + * Set the owner of a team. + * @param teamId The team's id + * @param userId The user's id + */ + public async setOwner(teamId: number, userId: number): Promise { + await this.put( + `/application/team/${teamId}/owner/${userId}`, + {} as never, + ); + } + /** * Delete a team by id * @param id The team's id diff --git a/frontend/src/components/base/button.tsx b/frontend/src/components/base/button.tsx index 4dd80234..82d8b932 100644 --- a/frontend/src/components/base/button.tsx +++ b/frontend/src/components/base/button.tsx @@ -17,7 +17,9 @@ const RegularButton = styled.button` font-size: 0.8rem; font-weight: bold; + white-space: nowrap; text-transform: uppercase; + cursor: pointer; background: #333; @@ -65,6 +67,7 @@ interface IButtonProps { primary?: boolean; loading?: boolean; color?: string; + style?: React.CSSProperties; } /** @@ -77,6 +80,7 @@ export const Button = ({ primary = false, loading = false, color, + style = {}, }: IButtonProps) => { const handleClick = useCallback( (event: React.MouseEvent) => { @@ -90,7 +94,11 @@ export const Button = ({ const Component = primary ? PrimaryButton : RegularButton; return ( - + {children} {loading && ( diff --git a/frontend/src/components/base/page-header.tsx b/frontend/src/components/base/page-header.tsx index 61ec3cdd..832666be 100644 --- a/frontend/src/components/base/page-header.tsx +++ b/frontend/src/components/base/page-header.tsx @@ -3,14 +3,24 @@ import * as React from "react"; import { NonGrowingFlexContainer } from "../base/flex"; import { Heading, Subheading } from "../base/headings"; import { Button } from "../base/button"; -import { InternalLink } from "../base/link"; import { Collapsible } from "../base/collapsible"; import { Routes } from "../../routes"; import { Divider } from "../base/divider"; +import { mediaBreakpoints } from "../../config"; const HeaderContainer = styled(NonGrowingFlexContainer)` + justify-content: space-between; + flex-direction: column; +`; + +const HeadingButtonContainer = styled.div` + display: flex; justify-content: space-between; flex-direction: row; + + @media screen and (max-width: ${mediaBreakpoints.tablet}) { + flex-direction: column; + } `; interface IPageHeaderProps { @@ -54,16 +64,18 @@ export const PageHeader = ({ ); return ( - -
+ + {buttonText && (buttonHref ? ( - {button} + + {button} + ) : ( button ))} -
+ {collapsibleText ? ( {collapsibleText} diff --git a/frontend/src/components/base/stack-with-border.tsx b/frontend/src/components/base/stack-with-border.tsx new file mode 100644 index 00000000..3ec743e7 --- /dev/null +++ b/frontend/src/components/base/stack-with-border.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; +import { Stack, Tooltip } from "@mui/material"; + +interface StackWithBorderProps { + text?: string; + tooltip?: string; + children?: React.ReactNode; +} + +/** + * I typically use this to display some text and a few buttons, + * with multiple of this on top of each other, like a table. + * Maybe these components should use tables instead, idk. It looks nice. + */ +export const StackWithBorder = ({ + text, + children, + tooltip, +}: StackWithBorderProps) => { + return ( +
+ + {text && ( +
+ + {text} + +
+ )} + {children} +
+
+ ); +}; diff --git a/frontend/src/components/pages/admission.tsx b/frontend/src/components/pages/admission.tsx index 85271b18..44a941a1 100644 --- a/frontend/src/components/pages/admission.tsx +++ b/frontend/src/components/pages/admission.tsx @@ -6,7 +6,6 @@ import { AnswerDTO, ApplicationDTO } from "../../api/types/dto"; import { QuestionType } from "../../api/types/enums"; import { debounceDuration } from "../../config"; import { useSettingsContext } from "../../contexts/settings-context"; -import { isNameQuestion, isTeamQuestion } from "../../heuristics"; import { performApiRequest, useApi } from "../../hooks/use-api"; import { useIsResponsive } from "../../hooks/use-is-responsive"; import { @@ -278,9 +277,6 @@ export const Admission = () => { }); }, [debouncedQuery, applicationsSortedByDate, applicationsByUserID]); - const probableNameQuestion = questions.find(isNameQuestion); - const probableTeamQuestion = questions.find(isTeamQuestion); - const [selectedRowIDs, setSelectedRowIDs] = useState([]); const headerCheckboxRef = useRef>(null); @@ -303,66 +299,6 @@ export const Admission = () => { } = useApi( async (api, wasForced) => { if (wasForced) { - if (probableTeamQuestion != null) { - const teams = new Set(); - - for (const id of selectedRowIDs) { - const { answersByQuestionID } = applicationsByUserID[id]; - const teamAnswer = answersByQuestionID[probableTeamQuestion.id!]; - - if (teamAnswer === null) { - continue; - } - - teams.add(teamAnswer); - } - - let firstSeenPartialTeam: Nullable = null; - let missingPartialTeamMemberEmails = ""; - for (const { user } of applicationsSortedByDate) { - const { answersByQuestionID } = applicationsByUserID[user.id]; - const teamAnswer = answersByQuestionID[probableTeamQuestion.id!]; - - if (teamAnswer == null) { - continue; - } - - const isSelectedTeam = teams.has(teamAnswer); - const isNotSelected = !selectedRowIDs.includes(user.id); - const isNotYetAdmitted = !user.admitted; - const isMemberOfFirstPartialTeam = - firstSeenPartialTeam == null || - firstSeenPartialTeam === teamAnswer; - - if ( - isSelectedTeam && - isNotSelected && - isNotYetAdmitted && - isMemberOfFirstPartialTeam - ) { - firstSeenPartialTeam = teamAnswer; - missingPartialTeamMemberEmails += `- ${user.email}\n`; - } - } - - if (firstSeenPartialTeam != null) { - const choice = prompt( - `You're about to admit the team '${firstSeenPartialTeam}', but you missed some team members:\n\n` + - `${missingPartialTeamMemberEmails}\n` + - `Type 'show' (without quotes) to view the missing team members, or type 'ignore' (without quotes) to continue partially admitting this team.`, - ); - - if (choice == null) { - return; - } - - if (choice !== "ignore") { - setQuery(firstSeenPartialTeam); - return; - } - } - } - const applicationList = selectedRowIDs .map((id) => `- ${applicationsByUserID[id].email}`) .join("\n"); @@ -385,7 +321,6 @@ export const Admission = () => { [ selectedRowIDs, reloadApplications, - probableTeamQuestion, applicationsByUserID, applicationsSortedByDate, ], @@ -398,7 +333,7 @@ export const Admission = () => { const isResponsive = useIsResponsive(); const tableRows = useMemo(() => { - return visibleApplications.map(({ user, teams }, userIndex) => { + return visibleApplications.map(({ user }, userIndex) => { const { id, email, @@ -411,9 +346,6 @@ export const Admission = () => { checkedIn, } = user; - const teamNumber = teams.length; - const teamNames = teams; - const name = user.firstName + " " + user.lastName; const isRowSelected = selectedRowIDs.includes(id); @@ -542,6 +474,10 @@ export const Admission = () => { RowComponent = AdmittedRow; } + const teamIdentifier = user.team + ? `${user.team?.title} #${user.team?.id}` + : ""; + const cityIndex = questions.find((q) => q.title === "City")?.id!; const countryIndex = questions.find((q) => q.title === "Country")?.id!; const genderIndex = questions.find((q) => q.title === "Gender")?.id!; @@ -559,7 +495,7 @@ export const Admission = () => { {userIndex + 1} {email} {name} - {teamNumber} + {teamIdentifier} {answersByQuestionID[genderIndex] === "Male" ? (
@@ -602,10 +538,8 @@ export const Admission = () => { - - {teamNames.map((teamName, index) => ( -
  • {teamName}
  • - ))} + + {teamIdentifier}
    @@ -671,7 +605,6 @@ export const Admission = () => { }, [ isResponsive, visibleApplications, - probableNameQuestion, selectedRowIDs, expandedRowIDs, applicationsByUserID, @@ -821,7 +754,7 @@ export const Admission = () => { Index E-mail Firstname / Lastname - Teams + Team Gender Created At City/Location diff --git a/frontend/src/components/pages/createTeam.tsx b/frontend/src/components/pages/createTeam.tsx index b8dc6bf9..89fa347b 100644 --- a/frontend/src/components/pages/createTeam.tsx +++ b/frontend/src/components/pages/createTeam.tsx @@ -1,13 +1,10 @@ import * as React from "react"; import { Page } from "./page"; -import { Button } from "../base/button"; import { TextInput, TextInputType } from "../base/text-input"; import { useApi } from "../../hooks/use-api"; import { Redirect } from "react-router"; import { Routes } from "../../routes"; -import { Autocomplete, Box, InputLabel, TextField } from "@mui/material"; -import { MdDeleteOutline } from "react-icons/md"; -import { UserListDto } from "../../api/types/dto"; +import { Alert } from "@mui/material"; import { useLoginContext } from "../../contexts/login-context"; import { Message } from "../base/message"; import { PageHeader } from "../base/page-header"; @@ -17,14 +14,11 @@ import { PageHeader } from "../base/page-header"; */ export const CreateTeam = () => { const loginState = useLoginContext(); - const { user } = loginState; + const { user, updateUser } = loginState; const [title, setTitle] = React.useState(""); const [description, setDescription] = React.useState(""); const [teamImg, setTeamImg] = React.useState(""); - const [users, setUsers] = React.useState([ - { id: user?.id, name: user?.firstName + " " + user?.lastName }, - ] as UserListDto[]); const { value: didCreateTeam, @@ -34,48 +28,35 @@ export const CreateTeam = () => { } = useApi( async (api, wasTriggeredManually) => { if (wasTriggeredManually) { - await api.createTeam( - title, - description, - teamImg, - users.map((u) => u.id), - ); + const team = await api.createTeam(title, description, teamImg); + await updateUser(() => ({ + ...user!, + team, + })); return true; } return false; }, - [title, description, teamImg, users], + [title, description, teamImg], ); - const { value: allUsers } = useApi(async (api) => api.getAllUsers(), []); - - const userList = allUsers ?? []; - const handleSubmit = React.useCallback((event: React.SyntheticEvent) => { event.preventDefault(); }, []); const createTeamDone = Boolean(didCreateTeam) && !createTeamInProgress && !createTeamError; - if (createTeamDone) { return ; } - function addMember() { - setUsers([...users, { id: 0, name: "" }]); - } - - function onChange(index: number, value: UserListDto) { - setUsers((u) => { - const newUsers = [...u]; - newUsers[index] = value; - return newUsers; - }); - } - - function alreadyInList(singleUser: UserListDto) { - return users.some((u) => u.id === singleUser.id); + if (user?.team != null) { + return ( + + + You are already in a team + + ); } return ( @@ -116,80 +97,6 @@ export const CreateTeam = () => { onChange={(value) => setTeamImg(value)} type={TextInputType.Text} /> -
    - - Select Team Members - - {users.map((singleUser, index) => ( -
    - - typeof option === "string" ? option : option.name - } - renderInput={(params) => ( - - )} - renderOption={(props, option, _state, ownerState) => ( - - {ownerState.getOptionLabel(option)}{" "} - {alreadyInList(option) ? " - already a member" : ""} - - )} - onChange={(_e, v) => onChange(index, v as UserListDto)} - /> - {index === 0 ? null : ( - { - const newUsers = [...users]; - newUsers.splice(index, 1); - setUsers(newUsers); - }} - /> - )} -
    - ))} - -
    - -
    -
    ); diff --git a/frontend/src/components/pages/edit-team.tsx b/frontend/src/components/pages/edit-team.tsx new file mode 100644 index 00000000..589feeb5 --- /dev/null +++ b/frontend/src/components/pages/edit-team.tsx @@ -0,0 +1,338 @@ +import * as React from "react"; +import { Subheading } from "../base/headings"; +import { Page } from "./page"; +import { Button } from "../base/button"; +import { RoundedImage } from "../base/image"; +import { Spacer } from "../base/flex"; +import { TextInput, TextInputType } from "../base/text-input"; +import { api, useApi } from "../../hooks/use-api"; +import { Card, CardContent } from "@mui/material"; +import { UserListDto, TeamResponseDTO } from "../../api/types/dto"; +import { useLoginContext } from "../../contexts/login-context"; +import { useHistory } from "react-router-dom"; +import { Message } from "../base/message"; +import { UserRole } from "../../api/types/enums"; +import { PageHeader } from "../base/page-header"; +import { useNotificationContext } from "../../contexts/notification-context"; +import { StackWithBorder } from "../base/stack-with-border"; + +interface TeamMemberRequestProps { + user: UserListDto; + updateTeamInProgress: boolean; + acceptUserToTeam: (user: UserListDto) => void; +} + +const TeamMemberRequest = ({ + user, + updateTeamInProgress, + acceptUserToTeam, +}: TeamMemberRequestProps) => { + return ( + + + + ); +}; + +interface TeamMemberProps { + team: TeamResponseDTO; + user: UserListDto; + updateTeamInProgress: boolean; + onSetOwner: (user: UserListDto) => void; + onRemove: (user: UserListDto) => void; +} + +const TeamMember = ({ + team, + user, + updateTeamInProgress, + onSetOwner, + onRemove, +}: TeamMemberProps) => { + const { user: loginStateUser } = useLoginContext(); + const loggedInAsAdmin = loginStateUser?.role === UserRole.Root; + const loggedInAsOwner = loginStateUser?.id === team.owner?.id; + const editAllowed = loggedInAsAdmin || loggedInAsOwner; + + const memberIsOwner = team.owner?.id === user.id; + const thisIsYou = user.id === loginStateUser?.id; + + if (!editAllowed) { + return ( +

    + {user.firstName} + {memberIsOwner ? "(Owner)" : ""} +

    + ); + } + + return ( + + + {memberIsOwner ? ( + + ) : ( + + )} + + ); +}; + +/** + * A settings dashboard to configure all parts of tilt. + */ +export const EditTeam = ({ + onChange, + team, +}: { + onChange: () => void; + team: TeamResponseDTO; +}) => { + if (team == null) { + return null; + } + + const loginState = useLoginContext(); + const { user, updateUser } = loginState; + + const { showNotification } = useNotificationContext(); + + const [title, setTitle] = React.useState(""); + const [description, setDescription] = React.useState(""); + const [image, setImage] = React.useState(""); + + const history = useHistory(); + + const isTeamOwner = team.owner?.id === user?.id; + + const { + isFetching: updateTeamInProgress, + error: updateTeamError, + forcePerformRequest: sendSaveTeamRequest, + } = useApi( + async (apiClient, wasTriggeredManually) => { + if (wasTriggeredManually) { + await apiClient.updateTeam({ + id: team.id, + title, + description, + teamImg: image, + }); + showNotification("Saved"); + onChange(); + return true; + } + return false; + }, + [team, title, description, image, showNotification, onChange], + ); + + const acceptUserToTeam = async (userToAccept: UserListDto) => { + await api.acceptUserToTeam(team.id, userToAccept.id); + showNotification("Accepted user"); + onChange(); + }; + + const removeTeamFromUser = async () => { + if (user?.team?.id === team.id) { + await updateUser(() => ({ + ...user, + team: null, + })); + } + }; + + const removeUserFromTeam = async (userToRemove: UserListDto) => { + await api.removeUserFromTeam(team.id, userToRemove.id); + await removeTeamFromUser(); + showNotification("Removed user"); + onChange(); + }; + + const onSetOwner = async (newOwner: UserListDto) => { + await api.setOwner(team.id, newOwner.id); + showNotification("Changed owner"); + onChange(); + }; + + const { isFetching: deleteInProgress, forcePerformRequest: deleteTeam } = + useApi(async (apiClient, wasTriggeredManually) => { + if (wasTriggeredManually) { + if (confirm("Are you sure you want to delete this team?")) { + await apiClient.deleteTeam(team.id); + await removeTeamFromUser(); + history.push("/teams"); + return true; + } + } + return false; + }, []); + + const handleSubmit = React.useCallback((event: React.SyntheticEvent) => { + event.preventDefault(); + }, []); + + React.useEffect(() => { + if (team) { + setTitle(team.title); + setDescription(team.description); + setImage(team.teamImg); + } + }, [team]); + + const isAdmin = user?.role === UserRole.Root; + + return ( + + + {updateTeamError && ( +
    + + Update Team Error: {updateTeamError.message} + +
    + )} +
    + setTitle(value)} + type={TextInputType.Text} + /> + setDescription(value)} + type={TextInputType.Area} + /> +
    + setImage(value)} + type={TextInputType.Text} + /> + {image !== "" ? ( + + ) : null} +
    + +
    +

    Team Members

    + { + // Team Owners have to chose a different owner first. + // Admins shouldn't be part of the team. If they are, they can still + // use the "Remove" button to leave. + !isAdmin && !isTeamOwner && user && ( + + ) + } +
    + {team.users.map((teamMember) => ( + + + + ))} + {team.requests.length > 0 &&

    Requests

    } + {(isTeamOwner || isAdmin) && + team.requests.map((requestingUser) => ( + + + + + ))} +
    +
    + {isTeamOwner || isAdmin ? ( +
    + + + +

    + Please be aware: if you delete a team it is gone. We will not + be able to recover it. +

    +
    + +
    +
    +
    +
    + ) : null} + +
    + ); +}; diff --git a/frontend/src/components/pages/rating-form.tsx b/frontend/src/components/pages/rating-form.tsx index 5f6d2458..e229c5ff 100644 --- a/frontend/src/components/pages/rating-form.tsx +++ b/frontend/src/components/pages/rating-form.tsx @@ -1,17 +1,16 @@ import React, { useState } from "react"; import { - Stack, FormControl, RadioGroup, FormControlLabel, Radio, - Tooltip, } from "@mui/material"; import { api } from "../../hooks/use-api"; import { useLoginContext } from "../../contexts/login-context"; import { Button } from "../base/button"; import { CriterionDTO, ProjectDTO, RatingDTO } from "../../api/types/dto"; import { useNotificationContext } from "../../contexts/notification-context"; +import { StackWithBorder } from "../base/stack-with-border"; interface IRatingFormProps { rating?: RatingDTO; @@ -58,51 +57,32 @@ export const RatingForm = ({ }; return ( -
    - -
    - - {criterion.title} - -
    - - setRatingValue(parseInt(e.target.value, 10))} - > - {[1, 2, 3, 4, 5].map((value) => ( - } - label={value.toString()} - /> - ))} - - -
    - -
    -
    -
    + + + setRatingValue(parseInt(e.target.value, 10))} + > + {[1, 2, 3, 4, 5].map((value) => ( + } + label={value.toString()} + /> + ))} + + +
    + +
    +
    ); }; diff --git a/frontend/src/components/pages/read-only-project.tsx b/frontend/src/components/pages/read-only-project.tsx index fd3d8d4b..4ee7516f 100644 --- a/frontend/src/components/pages/read-only-project.tsx +++ b/frontend/src/components/pages/read-only-project.tsx @@ -1,18 +1,29 @@ import * as React from "react"; +import { Alert } from "@mui/material"; import { FlexRowContainer, Spacer } from "../base/flex"; import { Page } from "./page"; import { RoundedImage } from "../base/image"; import { api } from "../../hooks/use-api"; import { PageHeader } from "../base/page-header"; import { RatingForm } from "./rating-form"; -import { CriterionDTO, RatingDTO, ProjectDTO } from "../../api/types/dto"; +import { + CriterionDTO, + RatingDTO, + ProjectDTO, + SettingsDTO, +} from "../../api/types/dto"; +import { useLoginContext } from "../../contexts/login-context"; +import { UserRole } from "../../api/types/enums"; /** * A settings dashboard to configure all parts of tilt. */ export const ReadOnlyProject = ({ project }: { project: ProjectDTO }) => { + const { user } = useLoginContext(); + const [criteria, setCriteria] = React.useState([]); const [ratings, setRatings] = React.useState([]); + const [settings, setSettings] = React.useState>({}); React.useEffect(() => { api.getAllCriteria().then((criteria_) => { @@ -24,17 +35,27 @@ export const ReadOnlyProject = ({ project }: { project: ProjectDTO }) => { setRatings([...ratings_]); }); } + + api.getSettings().then((settings_) => { + setSettings(settings_); + }); }, [project]); + const image = project?.image || project?.team.teamImg; + const userAllowedToRate = + user?.role === UserRole.Root || (user?.team != null && user?.admitted); + const ratingEnabled = + project?.allowRating && settings?.project?.allowRatingProjects; + return (
    - {project?.image !== "" ? ( + {image !== "" ? ( ) : null} @@ -43,18 +64,25 @@ export const ReadOnlyProject = ({ project }: { project: ProjectDTO }) => {

    {project?.description}

    -
    -

    Rate this Project

    - Hover criteria for more information. Rate a criterion high, if you think - the project did well in this regard. - {criteria.map((criterion) => ( - r.criterion.id === criterion.id)} - criterion={criterion} - project={project} - /> - ))} -
    + {!userAllowedToRate && ratingEnabled && ( + + You need to be admitted and part of a team to rate other projects + + )} + {userAllowedToRate && ratingEnabled && ( +
    +

    Rate this Project

    + Hover criteria for more information. Rate a criterion high, if you + think the project did well in this regard. + {criteria.map((criterion) => ( + r.criterion.id === criterion.id)} + criterion={criterion} + project={project} + /> + ))} +
    + )} ); }; diff --git a/frontend/src/components/pages/read-only-team.tsx b/frontend/src/components/pages/read-only-team.tsx index f73c3bc2..d72d44c2 100644 --- a/frontend/src/components/pages/read-only-team.tsx +++ b/frontend/src/components/pages/read-only-team.tsx @@ -7,6 +7,7 @@ import { useApi } from "../../hooks/use-api"; import { useLoginContext } from "../../contexts/login-context"; import { PageHeader } from "../base/page-header"; import { TeamResponseDTO } from "../../api/types/dto"; +import { useNotificationContext } from "../../contexts/notification-context"; /** * A team view component. This is only displayed, if the user is not part @@ -20,10 +21,13 @@ export const ReadOnlyTeam = ({ team }: { team: TeamResponseDTO }) => { const [isTeamOwner, setIsTeamOwner] = React.useState(false); const [, setIsTeamMember] = React.useState(false); + const { showNotification } = useNotificationContext(); + const { forcePerformRequest: sendRequestToJoin } = useApi( async (apiClient, wasTriggeredManually) => { if (wasTriggeredManually) { await apiClient.requestToJoinTeam(Number(params.get("id"))); + showNotification("Request sent"); return true; } return false; @@ -31,13 +35,6 @@ export const ReadOnlyTeam = ({ team }: { team: TeamResponseDTO }) => { [], ); - function notInUserList() { - return ( - !team?.users?.some((u) => u.id === user?.id) && - !team?.requests?.some((u) => u.id === user?.id) - ); - } - React.useEffect(() => { if (team) { setIsTeamOwner(user?.id === Number(team?.users?.[0]?.id)); @@ -45,6 +42,11 @@ export const ReadOnlyTeam = ({ team }: { team: TeamResponseDTO }) => { } }, [team, user?.id]); + // When leaving a team, the parent component will reload team, and be more + // up to date than user. + const inTeam = team?.users.some(({ id }) => id === user?.id); + const hasRequested = team?.requests.some(({ id }) => id === user?.id); + return ( @@ -62,29 +64,21 @@ export const ReadOnlyTeam = ({ team }: { team: TeamResponseDTO }) => {

    {team?.description}

    - {!isTeamOwner && notInUserList() ? ( -
    - -
    - ) : null} - -
    -

    - Team Members -

    +
    +

    Team Members

    + {!isTeamOwner && !inTeam && !hasRequested ? ( +
    + +
    + ) : null} + {hasRequested && "You requested to join this team"}
    {team?.users?.map((singleUser, index) => (
    - {singleUser.name} + {singleUser.firstName}{" "} + {singleUser.id === team.owner?.id && " (Owner)"}
    ))}
    diff --git a/frontend/src/components/pages/status.tsx b/frontend/src/components/pages/status.tsx index a8c30ae7..c2934069 100644 --- a/frontend/src/components/pages/status.tsx +++ b/frontend/src/components/pages/status.tsx @@ -548,7 +548,7 @@ export const Status = () => { WebkitBoxOrient: "vertical", }} > - New Feature this year. You can create or join a team. + You can create or join a team.

    diff --git a/frontend/src/components/pages/teams.tsx b/frontend/src/components/pages/teams.tsx index 091b300e..e777e17e 100644 --- a/frontend/src/components/pages/teams.tsx +++ b/frontend/src/components/pages/teams.tsx @@ -23,11 +23,9 @@ export const Teams = () => { buttonText="Create New Team" buttonHref={Routes.CreateTeam} subTitle="Create or join a team" - collapsibleText="You can create or join a team. You can - add other users to your team and remove them as well. The team owner can - delete the team and remove users from the team. If you want to join a - team you can send a request to join the team and the team owner can - accept or reject the request." + collapsibleText="You can create or join a team. As the team owner you can + accept users who want to join, remove users, or delete the whole team. + If you want to join a team, go to the teams page and hit the button." /> {Array.from(teams).map((team: TeamDTO, index) => ( diff --git a/frontend/src/components/pages/view-project.tsx b/frontend/src/components/pages/view-project.tsx index 1b700702..5bc318c2 100644 --- a/frontend/src/components/pages/view-project.tsx +++ b/frontend/src/components/pages/view-project.tsx @@ -10,6 +10,7 @@ import { ReadOnlyProject } from "./read-only-project"; import { PageHeader } from "../base/page-header"; import { RoundedImage } from "../base/image"; import { ProjectDTO } from "../../api/types/dto"; +import { useNotificationContext } from "../../contexts/notification-context"; /** * A gate component that checks if the current user is part of the team. @@ -26,11 +27,8 @@ export const ViewProject = () => { api.getProjectByID(projectId).then((project_) => setProject(project_)); }, []); - const isTeamMember = React.useMemo(() => { - return ( - project?.team?.users?.some((id) => id === user?.id.toString()) ?? false - ); - }, [project, user?.id]); + const isTeamMember = project?.team?.id === user?.team?.id; + // TODO test that the refreshToken and login endpoints respond with user.team.id const isAdmin = user?.role === UserRole.Root; @@ -47,11 +45,14 @@ export const ViewProject = () => { /** * Team members may edit the project + * TODO move into separate file */ const EditProject = ({ project }: { project: ProjectDTO }) => { const loginState = useLoginContext(); const { user } = loginState; + const { showNotification } = useNotificationContext(); + const [id] = React.useState(project.id); const [title, setTitle] = React.useState(project.title); const [description, setDescription] = React.useState(project.description); @@ -71,6 +72,7 @@ const EditProject = ({ project }: { project: ProjectDTO }) => { image, allowRating, } as unknown as ProjectDTO); + showNotification("Saved"); return true; } return false; diff --git a/frontend/src/components/pages/view-team.tsx b/frontend/src/components/pages/view-team.tsx index 4b9d0c56..b03d5864 100644 --- a/frontend/src/components/pages/view-team.tsx +++ b/frontend/src/components/pages/view-team.tsx @@ -1,26 +1,10 @@ import * as React from "react"; -import { Subheading } from "../base/headings"; -import { Page } from "./page"; -import { Button } from "../base/button"; -import { RoundedImage } from "../base/image"; -import { TextInput, TextInputType } from "../base/text-input"; -import { api, useApi } from "../../hooks/use-api"; -import { - Autocomplete, - Box, - Card, - CardContent, - InputLabel, - TextField, -} from "@mui/material"; -import { MdDeleteOutline } from "react-icons/md"; -import { UserListDto, TeamResponseDTO } from "../../api/types/dto"; +import { api } from "../../hooks/use-api"; +import { TeamResponseDTO } from "../../api/types/dto"; import { useLoginContext } from "../../contexts/login-context"; -import { useHistory } from "react-router-dom"; -import { Message } from "../base/message"; import { ReadOnlyTeam } from "./read-only-team"; import { UserRole } from "../../api/types/enums"; -import { PageHeader } from "../base/page-header"; +import { EditTeam } from "./edit-team"; /** * A gate component that checks if the current user is part of the team. @@ -41,6 +25,10 @@ export const ViewTeam = () => { return team?.users?.some((u) => u.id === user?.id) ?? false; }, [team, user?.id]); + const reloadTeam = React.useCallback(async () => { + await api.getTeamByID(teamId).then((team_) => setTeam(team_)); + }, []); + const isAdmin = user?.role === UserRole.Root; if (!team) { @@ -48,364 +36,8 @@ export const ViewTeam = () => { } return isTeamMember || isAdmin ? ( - + ) : ( ); }; - -/** - * A settings dashboard to configure all parts of tilt. - */ -const EditTeam = ({ team }: { team: TeamResponseDTO }) => { - const loginState = useLoginContext(); - const { user } = loginState; - - const [currentUserId, setCurrentUserId] = React.useState(0); - const [isTeamOwner, setIsTeamOwner] = React.useState(false); - const [, setIsTeamMember] = React.useState(false); - const [id, setId] = React.useState(0); - const [title, setTitle] = React.useState(""); - const [description, setDescription] = React.useState(""); - const [teamImg, setTeamImg] = React.useState(""); - const [users, setUsers] = React.useState([] as UserListDto[]); - const [request, setRequest] = React.useState([] as UserListDto[]); - - const { - value: didUpdateTeam, - isFetching: updateTeamInProgress, - error: updateTeamError, - forcePerformRequest: sendSaveTeamRequest, - } = useApi( - async (apiClient, wasTriggeredManually) => { - if (wasTriggeredManually) { - await apiClient.updateTeam( - id, - title, - description, - teamImg, - users.map((u) => u.id), - ); - return true; - } - return false; - }, - [ - currentUserId, - isTeamOwner, - id, - title, - description, - teamImg, - users, - request, - ], - ); - - const { - value: didSendRequestToJoin, - forcePerformRequest: sendRequestToJoin, - } = useApi(async (apiClient, wasTriggeredManually) => { - if (wasTriggeredManually) { - await apiClient.requestToJoinTeam(team.id); - return true; - } - return false; - }, []); - - async function acceptUserToTeam(userId: number) { - await api.acceptUserToTeam( - team.id, - request.find((u) => u.id === userId)!.id, - ); - history.go(0); - } - - const { - value: didDelete, - isFetching: deleteInProgress, - error: deleteError, - forcePerformRequest: deleteGroup, - } = useApi(async (apiClient, wasTriggeredManually) => { - if (wasTriggeredManually) { - if (confirm("Are you sure you want to delete this team?")) { - await apiClient.deleteTeam(team.id); - return true; - } - } - return false; - }, []); - - const { value: allUsers } = useApi( - async (apiClient) => apiClient.getAllUsers(), - [], - ); - - const userList = allUsers ?? []; - - const handleSubmit = React.useCallback((event: React.SyntheticEvent) => { - event.preventDefault(); - }, []); - - const updateTeamDone = - Boolean(didUpdateTeam) && !updateTeamInProgress && !updateTeamError; - - const didDeleteDone = Boolean(didDelete) && !deleteInProgress && !deleteError; - - if (updateTeamDone || didSendRequestToJoin) { - const history = useHistory(); - history.go(0); - } - - if (didDeleteDone) { - const history = useHistory(); - history.push("/teams"); - } - - function addMember() { - setUsers([...users, { id: 0, name: "" }]); - } - - function onChange(index: number, value: UserListDto) { - setUsers((uList) => { - const newUsers = [...uList]; - newUsers[index] = value; - return newUsers; - }); - } - - function alreadyInList(singleUser: UserListDto) { - return users.some((u) => u.id === singleUser.id); - } - - React.useEffect(() => { - if (team) { - setCurrentUserId(team.id); - setId(team.id); - setTitle(team.title); - setDescription(team.description); - setTeamImg(team.teamImg); - setUsers(team.users!); - setRequest(team.requests!); - setIsTeamOwner(user?.id === Number(team?.users![0].id)); - setIsTeamMember(team.users!.some((u) => u.id === user?.id)); - } - }, [team]); - - function notInUserList() { - return ( - !users.some((u) => u.id === user?.id) && - !request.some((u) => u.id === user?.id) - ); - } - - return ( - - - {updateTeamError && ( -
    - - Update Team Error: {updateTeamError.message} - -
    - )} -
    - setTitle(value)} - type={TextInputType.Text} - /> - setDescription(value)} - type={TextInputType.Area} - /> -
    - setTeamImg(value)} - type={TextInputType.Text} - /> - {teamImg !== "" ? ( - - ) : null} - {!isTeamOwner && notInUserList() ? ( - - ) : null} -
    - -
    - - Team Members (can only be changed by the team owner) - -
    - {users.map((singleUser, index) => ( -
    - - typeof option === "string" ? option : option.name - } - renderInput={(paramsAuto) => ( - - )} - renderOption={(props, option, _state, ownerState) => ( - - {ownerState.getOptionLabel(option)}{" "} - {alreadyInList(option) ? " - already a member" : ""} - - )} - onChange={(_e, v) => onChange(index, v as UserListDto)} - /> - {index === 0 || !isTeamOwner ? null : ( - { - const newUsers = [...users]; - newUsers.splice(index, 1); - setUsers(newUsers); - }} - /> - )} -
    - ))} -
    - - {isTeamOwner ? ( -
    -
    - -
    -
    - ) : null} -
    - {request.length > 0 ? ( -
    - - These users requested to join this team (can only be changed by - the team owner) - - {request.map((r, index) => ( -
    - -
    - {!isTeamOwner ? null : ( - - )} -
    -
    - ))} -
    - ) : null} - - {isTeamOwner ? ( -
    - - - -

    - Please be aware if you delete a team it is gone. We will not - recover it. -

    -
    - -
    -
    -
    -
    - ) : null} - -
    - ); -}; diff --git a/frontend/src/components/routers/sidebar/sidebar-menu.tsx b/frontend/src/components/routers/sidebar/sidebar-menu.tsx index 1ee5c1df..9263ff4f 100644 --- a/frontend/src/components/routers/sidebar/sidebar-menu.tsx +++ b/frontend/src/components/routers/sidebar/sidebar-menu.tsx @@ -9,6 +9,8 @@ const UL = styled.ul` padding: 0; list-style: none; padding-left: 0.5rem; + flex: 1; + overflow-y: auto; `; interface ISidebarMenuProps { diff --git a/frontend/src/components/routers/sidebar/sidebar.tsx b/frontend/src/components/routers/sidebar/sidebar.tsx index e69000b1..9f045310 100644 --- a/frontend/src/components/routers/sidebar/sidebar.tsx +++ b/frontend/src/components/routers/sidebar/sidebar.tsx @@ -26,6 +26,7 @@ import { MdSupportAgent } from "react-icons/md"; const BackgroundContainer = styled(NonGrowingFlexContainer)` height: 100%; overflow-y: auto; + overflow-x: hidden; background: linear-gradient( to top right, ${variables.colorGradientStart}, @@ -106,8 +107,7 @@ export const Sidebar = () => {

    HACKABURG

    CONTROL CENTER

    - All important information about

    the Hackaburg 2026{" "} - event + Everything important about

    the Hackaburg 2026 event

    @@ -198,7 +198,7 @@ export const Sidebar = () => { )} -
    + -
    +
  • { - return ( - question.title.toLowerCase().includes("name") && - question.configuration.type === QuestionType.Text && - question.mandatory && - question.parentID == null - ); -}; - -/** - * A heuristic to determine whether a question is used to query for the team a - * user wants to be on. - * @param question The question to check - */ -export const isTeamQuestion = (question: QuestionDTO): boolean => { - return ( - question.title.toLowerCase().includes("team") && - question.configuration.type === QuestionType.Text - ); -}; diff --git a/frontend/test/__mocks__/api.ts b/frontend/test/__mocks__/api.ts index d789990c..8975b3ed 100644 --- a/frontend/test/__mocks__/api.ts +++ b/frontend/test/__mocks__/api.ts @@ -31,6 +31,7 @@ export const api: IMockedApi = { updateTeam: jest.fn(), requestToJoinTeam: jest.fn(), acceptUserToTeam: jest.fn(), + removeUserFromTeam: jest.fn(), deleteTeam: jest.fn(), getAllTeams: jest.fn(), getTeamByID: jest.fn(), @@ -45,4 +46,5 @@ export const api: IMockedApi = { getRatingResults: jest.fn(), createRating: jest.fn(), getUsersRatingsForProject: jest.fn(), + setOwner: jest.fn(), };