From cd36cb0ba2718e6bfa08765e7702d57dfe7fd733 Mon Sep 17 00:00:00 2001
From: Daniel Karbach <daniel.karbach@localhorst.tv>
Date: Mon, 11 Apr 2022 00:54:55 +0200
Subject: [PATCH] application admin UI

---
 app/Events/ApplicationChanged.php             | 40 +++++++++++++
 app/Events/ApplicationRemoved.php             | 41 +++++++++++++
 .../Controllers/ApplicationController.php     | 44 ++++++++++++++
 app/Http/Controllers/TournamentController.php |  2 +
 app/Models/Participant.php                    | 17 ++++++
 app/Models/Protocol.php                       | 51 ++++++++++++++++
 app/Policies/ApplicationPolicy.php            | 26 ++++++++
 .../js/components/applications/Button.js      | 53 ++++++++++++++++
 .../js/components/applications/Dialog.js      | 41 +++++++++++++
 resources/js/components/applications/Item.js  | 60 +++++++++++++++++++
 resources/js/components/applications/List.js  | 21 +++++++
 resources/js/components/common/Icon.js        |  3 +
 resources/js/components/pages/Tournament.js   |  7 +++
 resources/js/components/protocol/Item.js      | 16 +++++
 resources/js/components/tournament/Detail.js  |  2 +
 resources/js/helpers/Application.js           | 17 ++++++
 resources/js/helpers/Tournament.js            | 18 ++++++
 resources/js/helpers/permissions.js           |  3 +
 resources/js/i18n/de.js                       | 15 +++++
 resources/js/i18n/en.js                       | 15 +++++
 routes/api.php                                |  3 +
 21 files changed, 495 insertions(+)
 create mode 100644 app/Events/ApplicationChanged.php
 create mode 100644 app/Events/ApplicationRemoved.php
 create mode 100644 app/Http/Controllers/ApplicationController.php
 create mode 100644 resources/js/components/applications/Button.js
 create mode 100644 resources/js/components/applications/Dialog.js
 create mode 100644 resources/js/components/applications/Item.js
 create mode 100644 resources/js/components/applications/List.js
 create mode 100644 resources/js/helpers/Application.js

diff --git a/app/Events/ApplicationChanged.php b/app/Events/ApplicationChanged.php
new file mode 100644
index 0000000..eb37074
--- /dev/null
+++ b/app/Events/ApplicationChanged.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace App\Events;
+
+use App\Models\Application;
+use Illuminate\Broadcasting\Channel;
+use Illuminate\Broadcasting\InteractsWithSockets;
+use Illuminate\Broadcasting\PresenceChannel;
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
+use Illuminate\Foundation\Events\Dispatchable;
+use Illuminate\Queue\SerializesModels;
+
+class ApplicationChanged implements ShouldBroadcast
+{
+	use Dispatchable, InteractsWithSockets, SerializesModels;
+
+	/**
+	 * Create a new event instance.
+	 *
+	 * @return void
+	 */
+	public function __construct(Application $application)
+	{
+		$this->application = $application;
+	}
+
+	/**
+	 * Get the channels the event should broadcast on.
+	 *
+	 * @return \Illuminate\Broadcasting\Channel|array
+	 */
+	public function broadcastOn()
+	{
+		return new Channel('Tournament.'.$this->application->tournament_id);
+	}
+
+	public $application;
+
+}
diff --git a/app/Events/ApplicationRemoved.php b/app/Events/ApplicationRemoved.php
new file mode 100644
index 0000000..26ba162
--- /dev/null
+++ b/app/Events/ApplicationRemoved.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace App\Events;
+
+use Illuminate\Broadcasting\Channel;
+use Illuminate\Broadcasting\InteractsWithSockets;
+use Illuminate\Broadcasting\PresenceChannel;
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
+use Illuminate\Foundation\Events\Dispatchable;
+use Illuminate\Queue\SerializesModels;
+
+class ApplicationRemoved implements ShouldBroadcast
+{
+	use Dispatchable, InteractsWithSockets, SerializesModels;
+
+	/**
+	 * Create a new event instance.
+	 *
+	 * @return void
+	 */
+	public function __construct($application_id, $tournament_id)
+	{
+		$this->application_id = $application_id;
+		$this->tournament_id = $tournament_id;
+	}
+
+	/**
+	 * Get the channels the event should broadcast on.
+	 *
+	 * @return \Illuminate\Broadcasting\Channel|array
+	 */
+	public function broadcastOn()
+	{
+		return new Channel('Tournament.'.$this->tournament_id);
+	}
+
+	public $application_id;
+	public $tournament_id;
+
+}
diff --git a/app/Http/Controllers/ApplicationController.php b/app/Http/Controllers/ApplicationController.php
new file mode 100644
index 0000000..b0ab063
--- /dev/null
+++ b/app/Http/Controllers/ApplicationController.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Events\ApplicationChanged;
+use App\Events\ApplicationRemoved;
+use App\Models\Application;
+use App\Models\Participant;
+use App\Models\Protocol;
+use Illuminate\Http\Request;
+
+class ApplicationController extends Controller
+{
+
+	public function accept(Request $request, Application $application) {
+		$this->authorize('accept', $application);
+
+		$participant = Participant::firstOrCreate([
+			'tournament_id' => $application->tournament_id,
+			'user_id' => $application->user_id,
+		]);
+		$participant->makeRunner();
+
+		$application->delete();
+		ApplicationRemoved::dispatch($application->id, $application->tournament_id);
+
+		Protocol::applicationAccepted($application->tournament, $application, $request->user());
+
+		return $participant->toJson();
+	}
+
+	public function reject(Request $request, Application $application) {
+		$this->authorize('reject', $application);
+
+		$application->denied = true;
+		$application->save();
+		ApplicationChanged::dispatch($application);
+
+		Protocol::applicationRejected($application->tournament, $application, $request->user());
+
+		return $application->toJson();
+	}
+
+}
diff --git a/app/Http/Controllers/TournamentController.php b/app/Http/Controllers/TournamentController.php
index 9d62c23..6ef0038 100644
--- a/app/Http/Controllers/TournamentController.php
+++ b/app/Http/Controllers/TournamentController.php
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
 
 use App\Events\ApplicationAdded;
 use App\Models\Application;
+use App\Models\Protocol;
 use App\Models\Tournament;
 use Illuminate\Auth\Access\AuthorizationException;
 use Illuminate\Http\Request;
@@ -18,6 +19,7 @@ class TournamentController extends Controller
 		$application->user_id = $request->user()->id;
 		$application->save();
 		ApplicationAdded::dispatch($application);
+		Protocol::applicationReceived($tournament, $application, $request->user());
 		return $tournament->toJson();
 	}
 
diff --git a/app/Models/Participant.php b/app/Models/Participant.php
index 91b946d..38ecfda 100644
--- a/app/Models/Participant.php
+++ b/app/Models/Participant.php
@@ -82,6 +82,18 @@ class Participant extends Model
 		return in_array('admin', $this->roles);
 	}
 
+	public function makeRunner() {
+		if (!is_array($this->roles)) {
+			$this->roles = ['runner'];
+		} else if (!in_array('runner', $this->roles)) {
+			$newRoles = array_values($this->roles);
+			$newRoles[] = 'runner';
+			$this->roles = $newRoles;
+		}
+		$this->save();
+		ParticipantChanged::dispatch($this);
+	}
+
 
 	public function tournament() {
 		return $this->belongsTo(Tournament::class);
@@ -96,6 +108,11 @@ class Participant extends Model
 		'roles' => 'array',
 	];
 
+	protected $fillable = [
+		'tournament_id',
+		'user_id',
+	];
+
 	protected $with = [
 		'user',
 	];
diff --git a/app/Models/Protocol.php b/app/Models/Protocol.php
index 995f9c0..7a90851 100644
--- a/app/Models/Protocol.php
+++ b/app/Models/Protocol.php
@@ -10,6 +10,48 @@ class Protocol extends Model
 {
 	use HasFactory;
 
+	public static function applicationAccepted(Tournament $tournament, Application $application, User $user) {
+		$protocol = static::create([
+			'tournament_id' => $tournament->id,
+			'user_id' => $user->id,
+			'type' => 'application.accepted',
+			'details' => [
+				'tournament' => static::tournamentMemo($tournament),
+				'application' => static::applicationMemo($application),
+				'user' => static::userMemo($application->user),
+			],
+		]);
+		ProtocolAdded::dispatch($protocol);
+	}
+
+	public static function applicationReceived(Tournament $tournament, Application $application, User $user) {
+		$protocol = static::create([
+			'tournament_id' => $tournament->id,
+			'user_id' => $user->id,
+			'type' => 'application.received',
+			'details' => [
+				'tournament' => static::tournamentMemo($tournament),
+				'application' => static::applicationMemo($application),
+				'user' => static::userMemo($application->user),
+			],
+		]);
+		ProtocolAdded::dispatch($protocol);
+	}
+
+	public static function applicationRejected(Tournament $tournament, Application $application, User $user) {
+		$protocol = static::create([
+			'tournament_id' => $tournament->id,
+			'user_id' => $user->id,
+			'type' => 'application.rejected',
+			'details' => [
+				'tournament' => static::tournamentMemo($tournament),
+				'application' => static::applicationMemo($application),
+				'user' => static::userMemo($application->user),
+			],
+		]);
+		ProtocolAdded::dispatch($protocol);
+	}
+
 	public static function resultCommented(Tournament $tournament, Result $result, User $user) {
 		$protocol = static::create([
 			'tournament_id' => $tournament->id,
@@ -115,6 +157,13 @@ class Protocol extends Model
 	}
 
 
+	protected static function applicationMemo(Application $application) {
+		return [
+			'id' => $application->id,
+			'denied' => $application->denied,
+		];
+	}
+
 	protected static function resultMemo(Result $result) {
 		return [
 			'id' => $result->id,
@@ -137,6 +186,7 @@ class Protocol extends Model
 	protected static function tournamentMemo(Tournament $tournament) {
 		return [
 			'id' => $tournament->id,
+			'accept_applications' => $tournament->accept_applications,
 			'locked' => $tournament->locked,
 			'no_record' => $tournament->no_record,
 			'title' => $tournament->title,
@@ -149,6 +199,7 @@ class Protocol extends Model
 			'username' => $user->username,
 			'discriminator' => $user->discriminator,
 			'avatar' => $user->avatar,
+			'nickname' => $user->nickname,
 		];
 	}
 
diff --git a/app/Policies/ApplicationPolicy.php b/app/Policies/ApplicationPolicy.php
index 3dc9ad8..a816051 100644
--- a/app/Policies/ApplicationPolicy.php
+++ b/app/Policies/ApplicationPolicy.php
@@ -94,4 +94,30 @@ class ApplicationPolicy
 		return false;
 	}
 
+	/**
+	 * Determine whether the user can accept the application.
+	 *
+	 * @param  \App\Models\User  $user
+	 * @param  \App\Models\Application  $application
+	 * @return \Illuminate\Auth\Access\Response|bool
+	 */
+	public function accept(User $user, Application $application)
+	{
+		return $user->isAdmin()
+		   	|| $user->isTournamentAdmin($application->tournament);
+	}
+
+	/**
+	 * Determine whether the user can accept the application.
+	 *
+	 * @param  \App\Models\User  $user
+	 * @param  \App\Models\Application  $application
+	 * @return \Illuminate\Auth\Access\Response|bool
+	 */
+	public function reject(User $user, Application $application)
+	{
+		return $user->isAdmin()
+		   	|| $user->isTournamentAdmin($application->tournament);
+	}
+
 }
diff --git a/resources/js/components/applications/Button.js b/resources/js/components/applications/Button.js
new file mode 100644
index 0000000..415042b
--- /dev/null
+++ b/resources/js/components/applications/Button.js
@@ -0,0 +1,53 @@
+import PropTypes from 'prop-types';
+import React, { useState } from 'react';
+import { Badge, Button } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import Dialog from './Dialog';
+import Icon from '../common/Icon';
+import { mayHandleApplications } from '../../helpers/permissions';
+import { getPendingApplications } from '../../helpers/Tournament';
+import { withUser } from '../../helpers/UserContext';
+import i18n from '../../i18n';
+
+const ApplicationsButton = ({ tournament, user }) => {
+	const [showDialog, setShowDialog] = useState(false);
+
+	if (!user || !tournament.accept_applications || !mayHandleApplications(user, tournament)) {
+		return null;
+	}
+
+	const pending = getPendingApplications(tournament);
+
+	return <>
+		<Button
+			onClick={() => setShowDialog(true)}
+			title={i18n.t('tournaments.applications')}
+			variant="primary"
+		>
+			<Icon.APPLICATIONS title="" />
+			{pending.length ?
+				<>
+					{' '}
+					<Badge>{pending.length}</Badge>
+				</>
+			: null}
+		</Button>
+		<Dialog
+			onHide={() => setShowDialog(false)}
+			show={showDialog}
+			tournament={tournament}
+		/>
+	</>;
+};
+
+ApplicationsButton.propTypes = {
+	tournament: PropTypes.shape({
+		accept_applications: PropTypes.bool,
+		id: PropTypes.number,
+	}),
+	user: PropTypes.shape({
+	}),
+};
+
+export default withTranslation()(withUser(ApplicationsButton));
diff --git a/resources/js/components/applications/Dialog.js b/resources/js/components/applications/Dialog.js
new file mode 100644
index 0000000..a42fb3d
--- /dev/null
+++ b/resources/js/components/applications/Dialog.js
@@ -0,0 +1,41 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Alert, Button, Modal } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import List from './List';
+import i18n from '../../i18n';
+
+const Dialog = ({ onHide, show, tournament }) =>
+<Modal className="applications-dialog" onHide={onHide} show={show}>
+	<Modal.Header closeButton>
+		<Modal.Title>
+			{i18n.t('tournaments.applications')}
+		</Modal.Title>
+	</Modal.Header>
+	<Modal.Body className="p-0">
+		{tournament.applications && tournament.applications.length ?
+			<List tournament={tournament} />
+		:
+			<Alert variant="info">
+				{i18n.t('tournaments.noApplications')}
+			</Alert>
+		}
+	</Modal.Body>
+	<Modal.Footer>
+		<Button onClick={onHide} variant="secondary">
+			{i18n.t('button.close')}
+		</Button>
+	</Modal.Footer>
+</Modal>;
+
+Dialog.propTypes = {
+	onHide: PropTypes.func,
+	show: PropTypes.bool,
+	tournament: PropTypes.shape({
+		applications: PropTypes.arrayOf(PropTypes.shape({
+		}))
+	}),
+};
+
+export default withTranslation()(Dialog);
diff --git a/resources/js/components/applications/Item.js b/resources/js/components/applications/Item.js
new file mode 100644
index 0000000..8f6aef1
--- /dev/null
+++ b/resources/js/components/applications/Item.js
@@ -0,0 +1,60 @@
+import axios from 'axios';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, ListGroup } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+import toastr from 'toastr';
+
+import Icon from '../common/Icon';
+import Box from '../users/Box';
+import i18n from '../../i18n';
+
+const accept = async (tournament, application) => {
+	try {
+		await axios.post(`/api/application/${application.id}/accept`);
+		toastr.success(i18n.t('applications.acceptSuccess'));
+	} catch (e) {
+		toastr.error(i18n.t('applications.acceptError'));
+	}
+};
+
+const reject = async (tournament, application) => {
+	try {
+		await axios.post(`/api/application/${application.id}/reject`);
+		toastr.success(i18n.t('applications.rejectSuccess'));
+	} catch (e) {
+		toastr.error(i18n.t('applications.rejectError'));
+	}
+};
+
+const Item = ({ application, tournament }) =>
+<ListGroup.Item className="d-flex justify-content-between align-items-center">
+	<Box discriminator user={application.user} />
+	<div className="button-bar">
+		<Button
+			onClick={() => accept(tournament, application)}
+			title={i18n.t('applications.accept')}
+			variant="success"
+		>
+			<Icon.ACCEPT title="" />
+		</Button>
+		<Button
+			onClick={() => reject(tournament, application)}
+			title={i18n.t('applications.reject')}
+			variant="danger"
+		>
+			<Icon.REJECT title="" />
+		</Button>
+	</div>
+</ListGroup.Item>;
+
+Item.propTypes = {
+	application: PropTypes.shape({
+		user: PropTypes.shape({
+		}),
+	}),
+	tournament: PropTypes.shape({
+	}),
+};
+
+export default withTranslation()(Item);
diff --git a/resources/js/components/applications/List.js b/resources/js/components/applications/List.js
new file mode 100644
index 0000000..6460be3
--- /dev/null
+++ b/resources/js/components/applications/List.js
@@ -0,0 +1,21 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { ListGroup } from 'react-bootstrap';
+
+import Item from './Item';
+
+const List = ({ tournament }) =>
+<ListGroup variant="flush">
+	{tournament.applications.map(application =>
+		<Item application={application} key={application.id} tournament={tournament} />
+	)}
+</ListGroup>;
+
+List.propTypes = {
+	tournament: PropTypes.shape({
+		applications: PropTypes.arrayOf(PropTypes.shape({
+		})),
+	}),
+};
+
+export default List;
diff --git a/resources/js/components/common/Icon.js b/resources/js/components/common/Icon.js
index 607deb9..6d05090 100644
--- a/resources/js/components/common/Icon.js
+++ b/resources/js/components/common/Icon.js
@@ -57,8 +57,10 @@ const makePreset = (presetDisplayName, presetName) => {
 	return withTranslation()(preset);
 };
 
+Icon.ACCEPT = makePreset('AcceptIcon', 'square-check');
 Icon.ADD = makePreset('AddIcon', 'circle-plus');
 Icon.APPLY = makePreset('ApplyIcon', 'right-to-bracket');
+Icon.APPLICATIONS = makePreset('ApplicationsIcon', 'person-running');
 Icon.DISCORD = makePreset('DiscordIcon', ['fab', 'discord']);
 Icon.EDIT = makePreset('EditIcon', 'edit');
 Icon.FINISHED = makePreset('FinishedIcon', 'square-check');
@@ -69,6 +71,7 @@ Icon.LOCKED = makePreset('LockedIcon', 'lock');
 Icon.LOGOUT = makePreset('LogoutIcon', 'sign-out-alt');
 Icon.PENDING = makePreset('PendingIcon', 'clock');
 Icon.PROTOCOL = makePreset('ProtocolIcon', 'file-alt');
+Icon.REJECT = makePreset('RejectIcon', 'square-xmark');
 Icon.RESULT = makePreset('ResultIcon', 'clock');
 Icon.SECOND_PLACE = makePreset('SecondPlaceIcon', 'medal');
 Icon.STREAM = makePreset('StreamIcon', ['fab', 'twitch']);
diff --git a/resources/js/components/pages/Tournament.js b/resources/js/components/pages/Tournament.js
index 729e55d..00a1934 100644
--- a/resources/js/components/pages/Tournament.js
+++ b/resources/js/components/pages/Tournament.js
@@ -13,6 +13,7 @@ import {
 	patchResult,
 	patchRound,
 	patchUser,
+	removeApplication,
 	sortParticipants,
 } from '../../helpers/Tournament';
 
@@ -53,7 +54,13 @@ const Tournament = () => {
 					setTournament(tournament => patchApplication(tournament, e.application));
 				}
 			})
+			.listen('ApplicationRemoved', e => {
+				if (e.application_id) {
+					setTournament(tournament => removeApplication(tournament, e.application_id));
+				}
+			})
 			.listen('ParticipantChanged', e => {
+				console.log(e);
 				if (e.participant) {
 					setTournament(tournament => patchParticipant(tournament, e.participant));
 				}
diff --git a/resources/js/components/protocol/Item.js b/resources/js/components/protocol/Item.js
index 74bfa50..9fcc7e5 100644
--- a/resources/js/components/protocol/Item.js
+++ b/resources/js/components/protocol/Item.js
@@ -7,6 +7,7 @@ import { Trans, withTranslation } from 'react-i18next';
 import Icon from '../common/Icon';
 import Spoiler from '../common/Spoiler';
 import { formatTime } from '../../helpers/Result';
+import { getUserName } from '../../helpers/User';
 import i18n from '../../i18n';
 
 const getEntryDate = entry => {
@@ -16,6 +17,11 @@ const getEntryDate = entry => {
 		: dateStr;
 };
 
+const getEntryDetailsUsername = entry => {
+	if (!entry || !entry.details || !entry.details.user) return 'Anonymous';
+	return getUserName(entry.details.user);
+};
+
 const getEntryRoundNumber = entry =>
 	(entry && entry.details && entry.details.round && entry.details.round.number) || '?';
 
@@ -28,6 +34,16 @@ const getEntryResultTime = entry => {
 
 const getEntryDescription = entry => {
 	switch (entry.type) {
+		case 'application.accepted':
+		case 'application.received':
+		case 'application.rejected':
+			return i18n.t(
+				`protocol.description.${entry.type}`,
+				{
+					...entry,
+					username: getEntryDetailsUsername(entry),
+				},
+			);
 		case 'result.report': {
 			const time = getEntryResultTime(entry);
 			return <Trans i18nKey={`protocol.description.${entry.type}`}>
diff --git a/resources/js/components/tournament/Detail.js b/resources/js/components/tournament/Detail.js
index b20f208..cdc9b60 100644
--- a/resources/js/components/tournament/Detail.js
+++ b/resources/js/components/tournament/Detail.js
@@ -5,6 +5,7 @@ import { withTranslation } from 'react-i18next';
 
 import ApplyButton from './ApplyButton';
 import Scoreboard from './Scoreboard';
+import ApplicationsButton from '../applications/Button';
 import Protocol from '../protocol/Protocol';
 import Rounds from '../rounds/List';
 import Box from '../users/Box';
@@ -46,6 +47,7 @@ const Detail = ({
 			<div className="d-flex align-items-center justify-content-between">
 				<h1>{tournament.title}</h1>
 				<div className="button-bar">
+					<ApplicationsButton tournament={tournament} />
 					<ApplyButton tournament={tournament} />
 					{mayViewProtocol(user, tournament) ?
 						<Protocol id={tournament.id} />
diff --git a/resources/js/helpers/Application.js b/resources/js/helpers/Application.js
new file mode 100644
index 0000000..ee0b96d
--- /dev/null
+++ b/resources/js/helpers/Application.js
@@ -0,0 +1,17 @@
+import User from './User';
+
+export const compareUsername = (a, b) => {
+	const a_name = a && a.user ? User.getUserName(a.user) : '';
+	const b_name = b && b.user ? User.getUserName(b.user) : '';
+	return a_name.localeCompare(b_name);
+};
+
+export const isDenied = a => a && a.denied;
+
+export const isPending = a => a && !a.denied;
+
+export default {
+	compareUsername,
+	isDenied,
+	isPending,
+};
diff --git a/resources/js/helpers/Tournament.js b/resources/js/helpers/Tournament.js
index 25981d8..60354c2 100644
--- a/resources/js/helpers/Tournament.js
+++ b/resources/js/helpers/Tournament.js
@@ -1,3 +1,4 @@
+import Application from './Application';
 import Participant from './Participant';
 import Round from './Round';
 
@@ -15,6 +16,13 @@ export const findParticipant = (tournament, user) => {
 	return tournament.participants.find(p => p.user_id == user.id);
 };
 
+export const getPendingApplications = tournament => {
+	if (!tournament || !tournament.applications || !tournament.applications.length) return [];
+	return tournament.applications
+		.filter(Application.isPending)
+		.sort(Application.compareUsername);
+};
+
 export const getRunners = tournament => {
 	if (!tournament || !tournament.participants || !tournament.participants.length) return [];
 	return tournament.participants
@@ -132,6 +140,16 @@ export const patchUser = (tournament, user) => {
 	};
 };
 
+export const removeApplication = (tournament, id) => {
+	if (!tournament || !tournament.applications || !tournament.applications.find(a => a.id == id)) {
+		return tournament;
+	}
+	return {
+		...tournament,
+		applications: tournament.applications.filter(a => a.id != id),
+	};
+};
+
 export const sortParticipants = tournament => {
 	if (!tournament || !tournament.participants || !tournament.participants.length) {
 		return tournament;
diff --git a/resources/js/helpers/permissions.js b/resources/js/helpers/permissions.js
index cde0d05..18d03bf 100644
--- a/resources/js/helpers/permissions.js
+++ b/resources/js/helpers/permissions.js
@@ -58,6 +58,9 @@ export const mayApply = (user, tournament) =>
 	user && tournament && tournament.accept_applications &&
 		!isRunner(user, tournament) && !isApplicant(user, tournament);
 
+export const mayHandleApplications = (user, tournament) =>
+	tournament && tournament.accept_applications && isTournamentAdmin(user, tournament);
+
 export const mayLockRound = (user, tournament) =>
 	!tournament.locked && isTournamentAdmin(user, tournament);
 
diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js
index 76a5973..cc98394 100644
--- a/resources/js/i18n/de.js
+++ b/resources/js/i18n/de.js
@@ -1,6 +1,14 @@
 /* eslint-disable max-len */
 export default {
 	translation: {
+		applications: {
+			accept: 'Annehmen',
+			acceptError: 'Fehler beim Annehmen',
+			acceptSuccess: 'Angenommen',
+			reject: 'Ablehnen',
+			rejectSuccess: 'Abgelehnt',
+			rejectError: 'Fehler beim Ablehnen',
+		},
 		button: {
 			add: 'Hinzufügen',
 			back: 'Zurück',
@@ -125,6 +133,11 @@ export default {
 		},
 		protocol: {
 			description: {
+				application: {
+					accepted: 'Anmeldung von {{username}} bestätigt',
+					received: 'Anmeldung von {{username}} erhalten',
+					rejected: 'Anmeldung von {{username}} abgelehnt',
+				},
 				result: {
 					comment: 'Ergebnis kommentiert: "{{details.result.comment}}"',
 					report: 'Ergebnis von <0>{{time}}</0> eingetragen',
@@ -190,10 +203,12 @@ export default {
 			admins: 'Organisation',
 			applicationDenied: 'Antrag wurde abgelehnt',
 			applicationPending: 'Antrag wurde abgeschickt',
+			applications: 'Anmeldungen',
 			apply: 'Beitreten',
 			applyError: 'Fehler beim Abschicken der Anfrage',
 			applySuccess: 'Anfrage gestellt',
 			monitors: 'Monitore',
+			noApplications: 'Derzeit keine Anmeldungen',
 			noRecord: 'Turnier wird nicht gewertet',
 			scoreboard: 'Scoreboard',
 		},
diff --git a/resources/js/i18n/en.js b/resources/js/i18n/en.js
index 0b526ba..cd6dea0 100644
--- a/resources/js/i18n/en.js
+++ b/resources/js/i18n/en.js
@@ -1,6 +1,14 @@
 /* eslint-disable max-len */
 export default {
 	translation: {
+		applications: {
+			accept: 'Accept',
+			acceptError: 'Error accepting',
+			acceptSuccess: 'Accepted',
+			reject: 'Reject',
+			rejectSuccess: 'Rejected',
+			rejectError: 'Error rejecting',
+		},
 		button: {
 			add: 'Add',
 			back: 'Back',
@@ -125,6 +133,11 @@ export default {
 		},
 		protocol: {
 			description: {
+				application: {
+					accepted: 'Application from {{username}} accepted',
+					received: 'Application from {{username}} received',
+					rejected: 'Application from {{username}} rejected',
+				},
 				result: {
 					comment: 'Result commented: "{{details.result.comment}}"',
 					report: 'Result of {{time}} reported',
@@ -190,10 +203,12 @@ export default {
 			admins: 'Admins',
 			applicationDenied: 'Application denied',
 			applicationPending: 'Application pending',
+			applications: 'Applications',
 			apply: 'Apply',
 			applyError: 'Error submitting application',
 			applySuccess: 'Application sent',
 			monitors: 'Monitors',
+			noApplications: 'No applications at this point',
 			noRecord: 'Tournament set to not be recorded',
 			scoreboard: 'Scoreboard',
 		},
diff --git a/routes/api.php b/routes/api.php
index b9a4614..de8cd6c 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -18,6 +18,9 @@ Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
 	return $request->user();
 });
 
+Route::post('application/{application}/accept', 'App\Http\Controllers\ApplicationController@accept');
+Route::post('application/{application}/reject', 'App\Http\Controllers\ApplicationController@reject');
+
 Route::get('protocol/{tournament}', 'App\Http\Controllers\ProtocolController@forTournament');
 
 Route::post('results', 'App\Http\Controllers\ResultController@create');
-- 
2.39.5