Implement Discord appeal functionality, including database schema, API endpoints, front-end form, and Discord message handling.
This commit is contained in:
@@ -150,6 +150,11 @@ export const routes: Routes = [
|
||||
redirectTo: 'forms/appeal',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'discord-appeal',
|
||||
redirectTo: 'forms/discord-appeal',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'forms/appeal/:code',
|
||||
loadComponent: () => import('./pages/forms/appeal/appeal.component').then(m => m.AppealComponent),
|
||||
@@ -162,6 +167,12 @@ export const routes: Routes = [
|
||||
canActivate: [AuthGuard],
|
||||
data: {requiredAuthorizations: ['SCOPE_user']}
|
||||
},
|
||||
{
|
||||
path: 'forms/discord-appeal',
|
||||
loadComponent: () => import('./pages/forms/discord-appeal/discord-appeal.component').then(m => m.DiscordAppealComponent),
|
||||
canActivate: [AuthGuard],
|
||||
data: {requiredAuthorizations: ['SCOPE_user']}
|
||||
},
|
||||
{
|
||||
path: 'forms/sent',
|
||||
loadComponent: () => import('./pages/forms/sent/sent.component').then(m => m.SentComponent),
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<div>
|
||||
<app-header [current_page]="'appeal'" height="200px" background_image="/public/img/backgrounds/staff.png"
|
||||
[overlay_gradient]="0.5">
|
||||
<div class="title" header-content>
|
||||
<h1>Discord Appeal</h1>
|
||||
</div>
|
||||
</app-header>
|
||||
<main>
|
||||
<app-full-size>
|
||||
<section class="darkmodeSection appeal-container">
|
||||
<div class="form-container">
|
||||
<div class="pages">
|
||||
@if (currentPageIndex === 0) {
|
||||
<section class="formPage">
|
||||
<img ngSrc="/public/img/logos/logo.png" alt="Discord" height="319" width="550"/>
|
||||
<h1>Discord Appeal</h1>
|
||||
<p>We aim to respond within 48 hours.</p>
|
||||
<button mat-raised-button (click)="nextPage()">
|
||||
Next
|
||||
</button>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (currentPageIndex === 1) {
|
||||
<section class="formPage">
|
||||
<div class="description">
|
||||
<p>You are logged in as <strong>{{ authService.username() }}</strong>. If this is the correct
|
||||
account please continue</p>
|
||||
<br>
|
||||
<p><strong>Notice: </strong> Submitting an appeal is <strong>not</strong> an instant process.
|
||||
We will investigate the punishment you are appealing and respond within 48 hours.</p>
|
||||
<p style="font-style: italic;">Appeals that seem to have been made with
|
||||
little to no effort will be automatically denied.</p>
|
||||
</div>
|
||||
<button mat-raised-button (click)="nextPage()" [disabled]="authService.username() == null">
|
||||
I, {{ authService.username() }}, understand and agree
|
||||
</button>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (currentPageIndex === 2) {
|
||||
<section class="formPage">
|
||||
<div class="description">
|
||||
<p>Please enter your discord id below.</p>
|
||||
<p>You can find your discord id by going to User settings -> Advanced -> Developer Mode and turning it
|
||||
on</p>
|
||||
<p>With Developer Mode on in Discord click your profile in the bottom left and click Copy User Id</p>
|
||||
<p>We use this to find your punishment on our Discord server.</p>
|
||||
</div>
|
||||
<mat-form-field appearance="fill">
|
||||
<mat-label>Discord Id</mat-label>
|
||||
<input matInput placeholder="Discord Id" [(ngModel)]="discordId"
|
||||
maxlength="17" minlength="18" pattern="^[0-9]+$">
|
||||
</mat-form-field>
|
||||
<button mat-raised-button (click)="checkPunishment()" [disabled]="authService.username() == null">
|
||||
Check punishments
|
||||
</button>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (currentPageIndex === 3) {
|
||||
@if (bannedUser == null) {
|
||||
<section class="formPage">
|
||||
<div class="description">
|
||||
<p>We were unable to find your punishment on our Discord server.</p>
|
||||
</div>
|
||||
</section>
|
||||
} @else if (bannedUser.isBanned || bannedUser.bannedUser == null) {
|
||||
<section class="formPage">
|
||||
<div class="description">
|
||||
<p>Your discord account is not banned on our Discord server.</p>
|
||||
</div>
|
||||
</section>
|
||||
} @else {
|
||||
<section class="formPage">
|
||||
<div class="description">
|
||||
<img ngSrc="{{ bannedUser.bannedUser.avatarUrl }}" title="{{ bannedUser.bannedUser.name }}"
|
||||
width="128" height="128" class="discord-avatar"
|
||||
alt="Avatar for Discord user {{ bannedUser.bannedUser.name }}">
|
||||
<p style="text-align: center">{{ bannedUser.bannedUser.name }}</p>
|
||||
<p style="margin-top: 30px;">Your punishment is: <strong>{{ bannedUser.bannedUser.reason }}</strong>
|
||||
</p>
|
||||
<button style="display: block; margin-top: 30px;" class="centered" mat-raised-button
|
||||
(click)="nextPage()"
|
||||
[disabled]="authService.username() == null">
|
||||
This is my punishment, continue to appeal
|
||||
</button>
|
||||
</div>
|
||||
</section>)
|
||||
}
|
||||
}
|
||||
@if (currentPageIndex >= 4) {
|
||||
<form [formGroup]="form">
|
||||
@if (currentPageIndex === 4) {
|
||||
<section class="formPage">
|
||||
<div class="description">
|
||||
<h2>Please enter your email.</h2>
|
||||
<p style="font-style: italic">It does not have to be your minecraft email. You will have to verify
|
||||
it</p>
|
||||
<mat-form-field appearance="fill" style="width: 100%;">
|
||||
<mat-label>Email</mat-label>
|
||||
<input matInput
|
||||
formControlName="email"
|
||||
placeholder="Email"
|
||||
type="email">
|
||||
@if (form.controls.email.invalid && form.controls.email.touched) {
|
||||
<mat-error>
|
||||
@if (form.controls.email.errors?.['required']) {
|
||||
Email is required
|
||||
} @else if (form.controls.email.errors?.['email']) {
|
||||
Please enter a valid email address
|
||||
}
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
@if (emailIsValid()) {
|
||||
<div class="valid-email">
|
||||
<ng-container matSuffix>
|
||||
<mat-icon>check</mat-icon>
|
||||
<span>You have validated your email previously, and can continue to the next page!</span>
|
||||
</ng-container>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<button mat-raised-button (click)="validateMailOrNextPage()"
|
||||
[disabled]="form.controls.email.invalid">
|
||||
Next
|
||||
</button>
|
||||
</section>
|
||||
}
|
||||
@if (currentPageIndex === 5) {
|
||||
<section class="formPage">
|
||||
<div class="description">
|
||||
<h2>Why should your ban be reduced or removed?</h2>
|
||||
<p style="font-style: italic">Please take your time writing this, we're more likely to accept an
|
||||
appeal if effort was put into it.</p>
|
||||
<mat-form-field appearance="fill" style="width: 100%;">
|
||||
<mat-label>Reason</mat-label>
|
||||
<textarea matInput formControlName="appeal" placeholder="Reason" rows="6"></textarea>
|
||||
@if (form.controls.appeal.invalid && form.controls.appeal.touched) {
|
||||
<mat-error>
|
||||
@if (form.controls.appeal.errors?.['required']) {
|
||||
Reason is required
|
||||
} @else if (form.controls.appeal.errors?.['minlength']) {
|
||||
Reason must be at least 10 characters
|
||||
}
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<button mat-raised-button (click)="onSubmit()" [disabled]="form.invalid">
|
||||
Submit Appeal
|
||||
</button>
|
||||
</section>
|
||||
}
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Navigation dots -->
|
||||
@if (totalPages.length > 1) {
|
||||
<div class="form-navigation">
|
||||
<button mat-icon-button class="nav-button" (click)="previousPage()" [disabled]="isFirstPage()">
|
||||
<mat-icon>navigate_before</mat-icon>
|
||||
</button>
|
||||
|
||||
@for (i of totalPages; track i) {
|
||||
<div
|
||||
class="nav-dot"
|
||||
[class.active]="i === currentPageIndex"
|
||||
(click)="goToPage(i)">
|
||||
</div>
|
||||
}
|
||||
|
||||
<button mat-icon-button class="nav-button" (click)="nextPage()" [disabled]="isLastPage()">
|
||||
<mat-icon>navigate_next</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</app-full-size>
|
||||
</main>
|
||||
</div>
|
||||
@@ -0,0 +1,121 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.appeal-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.formPage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-buttons {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.form-navigation {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
|
||||
.nav-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
|
||||
&.active {
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
color: #1f9bde;
|
||||
}
|
||||
|
||||
.pages {
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
.description {
|
||||
max-width: 75ch;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.valid-email {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #4CAF50;
|
||||
margin: 10px 0;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(76, 175, 80, 0.1);
|
||||
}
|
||||
|
||||
.valid-email mat-icon {
|
||||
color: #4CAF50;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.valid-email span {
|
||||
color: #4CAF50;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.discord-avatar {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import {Component, computed, effect, inject, OnInit, signal} from '@angular/core';
|
||||
import {AppealsService, BannedUserResponse, DiscordAppeal, EmailEntry, MailService} from '@api';
|
||||
import {FullSizeComponent} from '@shared-components/full-size/full-size.component';
|
||||
import {HeaderComponent} from '@header/header.component';
|
||||
import {MatButton, MatIconButton} from '@angular/material/button';
|
||||
import {NgOptimizedImage} from '@angular/common';
|
||||
import {MatProgressSpinnerModule} from '@angular/material/progress-spinner';
|
||||
import {AuthService} from '@services/auth.service';
|
||||
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from '@angular/forms';
|
||||
import {MatFormFieldModule} from '@angular/material/form-field';
|
||||
import {MatInputModule} from '@angular/material/input';
|
||||
import {MatIconModule} from '@angular/material/icon';
|
||||
import {VerifyMailDialogComponent} from '@pages/forms/verify-mail-dialog/verify-mail-dialog.component';
|
||||
import {MatDialog} from '@angular/material/dialog';
|
||||
import {Router} from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-discord-appeal',
|
||||
imports: [
|
||||
FullSizeComponent,
|
||||
HeaderComponent,
|
||||
MatButton,
|
||||
MatProgressSpinnerModule,
|
||||
NgOptimizedImage,
|
||||
FormsModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatIconModule,
|
||||
ReactiveFormsModule,
|
||||
MatIconButton,
|
||||
],
|
||||
templateUrl: './discord-appeal.component.html',
|
||||
styleUrl: './discord-appeal.component.scss'
|
||||
})
|
||||
export class DiscordAppealComponent implements OnInit {
|
||||
private readonly appealService: AppealsService = inject(AppealsService)
|
||||
private readonly mailService = inject(MailService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly router = inject(Router)
|
||||
|
||||
private emails = signal<EmailEntry[]>([]);
|
||||
|
||||
protected readonly authService = inject(AuthService);
|
||||
|
||||
protected bannedUser: BannedUserResponse | null = null;
|
||||
protected discordId: string = window.location.hostname === 'localhost' ? '212303885988134914' : '';
|
||||
protected verifiedEmails = computed(() => this.emails()
|
||||
.filter(email => {
|
||||
console.log(email.verified)
|
||||
return email.verified
|
||||
})
|
||||
.map(email => {
|
||||
console.log(email.email.toLowerCase())
|
||||
return email.email.toLowerCase()
|
||||
}));
|
||||
protected emailIsValid = signal<boolean>(false);
|
||||
protected currentPageIndex: number = 0;
|
||||
protected totalPages: number[] = [0];
|
||||
protected form: FormGroup<WebDiscordAppeal>;
|
||||
|
||||
constructor() {
|
||||
this.form = new FormGroup({
|
||||
email: new FormControl('', {nonNullable: true, validators: [Validators.required, Validators.email]}),
|
||||
appeal: new FormControl('', {nonNullable: true, validators: [Validators.required, Validators.minLength(10)]})
|
||||
});
|
||||
effect(() => {
|
||||
if (this.verifiedEmails().length > 0) {
|
||||
console.log('verified emails')
|
||||
console.log(this.verifiedEmails()[0])
|
||||
this.form.get('email')?.setValue(this.verifiedEmails()[0]);
|
||||
this.emailIsValid.set(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (window.location.hostname === 'localhost') {
|
||||
this.emails.set([{email: '[email protected]', verified: true}])
|
||||
} else {
|
||||
this.mailService.getUserEmails().subscribe(emails => {
|
||||
this.emails.set(emails);
|
||||
});
|
||||
}
|
||||
this.form.valueChanges.subscribe(() => {
|
||||
if (this.verifiedEmails().includes(this.form.getRawValue().email.toLowerCase())) {
|
||||
this.emailIsValid.set(true);
|
||||
} else {
|
||||
this.emailIsValid.set(false);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
protected validateMailOrNextPage() {
|
||||
if (this.emailIsValid()) {
|
||||
this.nextPage();
|
||||
return;
|
||||
}
|
||||
const dialogRef = this.dialog.open(VerifyMailDialogComponent, {
|
||||
data: {email: this.form.getRawValue().email},
|
||||
});
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result === true) {
|
||||
this.emailIsValid.set(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public goToPage(pageIndex: number): void {
|
||||
if (pageIndex >= 0 && pageIndex < this.totalPages.length) {
|
||||
this.currentPageIndex = pageIndex;
|
||||
}
|
||||
}
|
||||
|
||||
public previousPage() {
|
||||
this.goToPage(this.currentPageIndex - 1);
|
||||
}
|
||||
|
||||
public nextPage() {
|
||||
if (this.currentPageIndex === this.totalPages.length - 1) {
|
||||
this.totalPages.push(this.currentPageIndex + 1);
|
||||
}
|
||||
this.goToPage(this.currentPageIndex + 1);
|
||||
}
|
||||
|
||||
public isFirstPage(): boolean {
|
||||
return this.currentPageIndex === 0;
|
||||
}
|
||||
|
||||
public isLastPage(): boolean {
|
||||
return this.currentPageIndex === this.totalPages.length - 1;
|
||||
}
|
||||
|
||||
protected checkPunishment() {
|
||||
if (window.location.hostname === 'localhost') {
|
||||
this.bannedUser = {
|
||||
isBanned: false,
|
||||
bannedUser: {
|
||||
userId: 212303885988134914,
|
||||
reason: "This is a test punishment",
|
||||
name: "stijn",
|
||||
avatarUrl: "https://cdn.discordapp.com/avatars/212303885988134914/3a264be54ca7208d638a22143fc8fdb8.webp?size=160"
|
||||
}
|
||||
}
|
||||
this.nextPage();
|
||||
return
|
||||
}
|
||||
this.appealService.getBannedUser(Number(this.discordId))
|
||||
.subscribe(user => {
|
||||
this.bannedUser = user
|
||||
this.nextPage();
|
||||
});
|
||||
}
|
||||
|
||||
protected onSubmit() {
|
||||
if (this.form === undefined) {
|
||||
console.error('Form is undefined');
|
||||
return
|
||||
}
|
||||
if (this.form.valid) {
|
||||
this.sendForm()
|
||||
} else {
|
||||
Object.keys(this.form.controls).forEach(field => {
|
||||
const control = this.form!.get(field);
|
||||
if (!(control instanceof FormGroup)) {
|
||||
console.error('Control [' + control + '] is not a FormGroup');
|
||||
return;
|
||||
}
|
||||
control.markAsTouched({onlySelf: true});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sendForm() {
|
||||
const rawValue = this.form.getRawValue();
|
||||
const uuid = this.authService.getUuid();
|
||||
if (uuid === null) {
|
||||
throw new Error('JWT subject is null, are you logged in?');
|
||||
}
|
||||
const appeal: DiscordAppeal = {
|
||||
discordId: Number(this.discordId),
|
||||
appeal: rawValue.appeal,
|
||||
email: rawValue.email,
|
||||
}
|
||||
this.appealService.submitDiscordAppeal(appeal).subscribe((result) => {
|
||||
if (!result.verified_mail) {
|
||||
throw new Error('Mail not verified');
|
||||
}
|
||||
this.router.navigate(['/forms/sent'], {
|
||||
state: {message: result.message}
|
||||
}).then();
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
interface WebDiscordAppeal {
|
||||
email: FormControl<string>;
|
||||
appeal: FormControl<string>;
|
||||
}
|
||||
@@ -34,7 +34,7 @@
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
|
||||
@if (!staffPt()?.length) {
|
||||
@if (!staffPt().length) {
|
||||
<tr class="no-data">
|
||||
<td colspan="3">No data for this week.</td>
|
||||
</tr>
|
||||
|
||||
@@ -132,8 +132,8 @@
|
||||
<li class="nav_li"><a class="nav_link2" [routerLink]="['/community']">Community</a></li>
|
||||
<li class="nav_li"><a class="nav_link2" target="_blank" rel="noopener" [routerLink]="['/contact']">Contact
|
||||
Us</a></li>
|
||||
<li class="nav_li"><a class="nav_link2" target="_blank" rel="noopener" href="https://alttd.com/appeal">Ban
|
||||
Appeal</a></li>
|
||||
<li class="nav_li"><a class="nav_link2" [routerLink]="['/appeal']">Ban Appeal</a></li>
|
||||
<li class="nav_li"><a class="nav_link2" [routerLink]="['/discord-appeal']">Discord Ban Appeal</a></li>
|
||||
<li class="nav_li"><a class="nav_link2" target="_blank" href="https://alttd.com/blog/">Blog</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<mat-form-field
|
||||
class="colorField"
|
||||
appearance="outline"
|
||||
[style.visibility]="(part.continuation && i>0 && parts[i-1]?.gradient && part.gradient) ? 'hidden' : 'visible'">
|
||||
[style.visibility]="(part.continuation && i>0 && parts[i-1].gradient && part.gradient) ? 'hidden' : 'visible'">
|
||||
<mat-label>Color A</mat-label>
|
||||
<input
|
||||
matInput
|
||||
@@ -67,7 +67,7 @@
|
||||
class="checkbox"
|
||||
[(ngModel)]="part.continuation"
|
||||
(change)="onContinuationToggle(i)"
|
||||
[disabled]="i===0 || !part.gradient || !parts[i-1]?.gradient"
|
||||
[disabled]="i===0 || !part.gradient || !parts[i-1].gradient"
|
||||
>Continuation
|
||||
</mat-checkbox
|
||||
>
|
||||
|
||||
@@ -45,6 +45,9 @@ export class AuthService {
|
||||
}
|
||||
|
||||
private reloadUsername() {
|
||||
if (window.location.hostname === 'localhost') {
|
||||
this._username.set('developer');
|
||||
}
|
||||
this.loginService.getUsername().subscribe({
|
||||
next: (username) => {
|
||||
this._username.set(username.username);
|
||||
|
||||
Reference in New Issue
Block a user