Merge remote-tracking branch 'origin/bans' into bans

# Conflicts:
#	frontend/src/app/app.routes.ts
This commit is contained in:
Peter
2025-07-06 11:13:59 +02:00
28 changed files with 651 additions and 66 deletions
+2
View File
@@ -13,10 +13,12 @@
},
"private": true,
"dependencies": {
"@angular/cdk": "^19.2.18",
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/material": "^19.2.18",
"@angular/platform-browser": "^19.2.0",
"@angular/platform-browser-dynamic": "^19.2.0",
"@angular/router": "^19.2.0",
+4 -8
View File
@@ -98,17 +98,13 @@ export const routes: Routes = [
loadComponent: () => import('./staffpowers/staffpowers.component').then(m => m.StaffpowersComponent)
},
{
path: 'nicknames',
loadComponent: () => import('./nicknames/nicknames.component').then(m => m.NicknamesComponent)
path: 'forms/:form',
loadComponent: () => import('./forms/forms.component').then(m => m.FormsComponent)
},
{
path: 'nickgenerator',
loadComponent: () => import('./nickgenerator/nickgenerator.component').then(m => m.NickgeneratorComponent)
path: 'forms',
loadComponent: () => import('./forms/forms.component').then(m => m.FormsComponent)
},
{
path: 'community',
loadComponent: () => import('./community/community.component').then(m => m.CommunityComponent)
}
];
+5
View File
@@ -0,0 +1,5 @@
export enum FormType {
APPEAL = 'appeal',
STAFF_APPLICATION = 'staff_application',
CONTACT = 'contact'
}
+7 -1
View File
@@ -5,7 +5,13 @@
<h1>{{ formTitle }}</h1>
</div>
</app-header>
<!-- TODO add form styling in this div-->
<ng-container *ngIf="!type">
<ng-container *ngFor="let formType of FormType | keyvalue">
<button mat-raised-button (click)="setFormType(formType.value)">
{{ formType }}
</button>
</ng-container>
</ng-container>
<div>
<ng-content select="[form-content]"></ng-content>
</div>
+56 -3
View File
@@ -1,15 +1,68 @@
import {Component, Input} from '@angular/core';
import {Component, Input, OnInit} from '@angular/core';
import {HeaderComponent} from '../header/header.component';
import {MatDialog} from '@angular/material/dialog';
import {ActivatedRoute} from '@angular/router';
import {LoginDialogComponent} from '../login/login.component';
import {KeyValuePipe, NgForOf, NgIf} from '@angular/common';
import {FormType} from './form_type';
import {MatButton} from '@angular/material/button';
import {AuthService} from '../services/auth.service';
@Component({
selector: 'app-forms',
imports: [
HeaderComponent
HeaderComponent,
NgIf,
NgForOf,
MatButton,
KeyValuePipe
],
templateUrl: './forms.component.html',
styleUrl: './forms.component.scss'
})
export class FormsComponent {
export class FormsComponent implements OnInit {
@Input() formTitle: string = 'Form';
@Input() currentPage: string = 'forms';
public type: FormType | undefined;
constructor(private authService: AuthService,
private dialog: MatDialog,
private route: ActivatedRoute,
) {
this.route.paramMap.subscribe(async params => {
const code = params.get('code');
if (code) {
this.authService.login(code).subscribe();
} else if (!this.authService.checkAuthStatus()) {
const dialogRef = this.dialog.open(LoginDialogComponent, {
width: '400px',
disableClose: true
});
dialogRef.afterClosed().subscribe();
}
});
}
ngOnInit() {
this.route.paramMap.subscribe(params => {
switch (params.get('form')) {
case FormType.APPEAL:
this.type = FormType.APPEAL;
this.currentPage = 'appeal';
break;
default:
throw new Error("Invalid type");
}
});
}
protected readonly FormType = FormType;
protected readonly Object = Object;
public setFormType(formType: FormType) {
this.type = formType;
}
}
@@ -0,0 +1,18 @@
<h2 mat-dialog-title>Login</h2>
<div mat-dialog-content>
<form [formGroup]="loginForm">
<mat-form-field appearance="fill" style="width: 100%">
<mat-label>Enter your code</mat-label>
<input matInput formControlName="code" type="text">
<mat-error *ngIf="formHasError()">
Code is required
</mat-error>
</mat-form-field>
</form>
</div>
<div mat-dialog-actions align="end">
<button mat-button (click)="onCancel()">Cancel</button>
<button mat-flat-button color="primary" (click)="onSubmit()" [disabled]="!loginForm.valid">
Submit
</button>
</div>
@@ -0,0 +1,13 @@
:host {
display: block;
width: 100%;
max-width: 400px;
}
.mat-dialog-content {
padding-top: 10px;
}
.mat-dialog-actions {
padding: 16px 0;
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { LoginComponent } from './login.component';
describe('LoginComponent', () => {
let component: LoginComponent;
let fixture: ComponentFixture<LoginComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [LoginComponent]
})
.compileComponents();
fixture = TestBed.createComponent(LoginComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
+71
View File
@@ -0,0 +1,71 @@
import {Component} from '@angular/core';
import {MatDialogActions, MatDialogContent, MatDialogRef, MatDialogTitle} from '@angular/material/dialog';
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {MatButtonModule} from '@angular/material/button';
import {MatInputModule} from '@angular/material/input';
import {MatFormFieldModule} from '@angular/material/form-field';
import {NgIf} from '@angular/common';
import {MatSnackBar} from '@angular/material/snack-bar';
import {AuthService} from '../services/auth.service';
@Component({
selector: 'app-login',
standalone: true,
imports: [
ReactiveFormsModule,
MatButtonModule,
MatInputModule,
MatFormFieldModule,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
NgIf
],
templateUrl: './login.component.html',
styleUrl: './login.component.scss'
})
export class LoginDialogComponent {
public loginForm: FormGroup;
constructor(
public dialogRef: MatDialogRef<LoginDialogComponent>,
private fb: FormBuilder,
private authService: AuthService,
private snackBar: MatSnackBar
) {
this.loginForm = this.fb.group({
code: ['', [
Validators.required,
Validators.minLength(8),
Validators.maxLength(8),
Validators.pattern('^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]+$')
]]
});
}
onCancel(): void {
this.dialogRef.close();
}
onSubmit(): void {
if (!this.loginForm.valid) {
this.snackBar.open('Invalid code', '', {duration: 2000});
return;
}
this.snackBar.open('Logging in...', '', {duration: 2000});
this.authService.login(this.loginForm.value.code).subscribe({
next: (jwt) => {
this.dialogRef.close(jwt);
},
error: () => {
this.loginForm.get('code')?.setErrors({
invalid: true
});
}
});
}
public formHasError() {
return this.loginForm.get('code')?.hasError('required');
}
}
+115
View File
@@ -0,0 +1,115 @@
import {Injectable} from '@angular/core';
import {LoginService} from '../../api';
import {CookieService} from 'ngx-cookie-service';
import {BehaviorSubject, Observable, throwError} from 'rxjs';
import {catchError, tap} from 'rxjs/operators';
import {MatSnackBar} from '@angular/material/snack-bar';
@Injectable({
providedIn: 'root'
})
export class AuthService {
private isAuthenticatedSubject = new BehaviorSubject<boolean>(false);
public isAuthenticated$ = this.isAuthenticatedSubject.asObservable();
private userClaimsSubject = new BehaviorSubject<any>(null);
public userClaims$ = this.userClaimsSubject.asObservable();
constructor(
private loginService: LoginService,
private cookieService: CookieService,
private snackBar: MatSnackBar
) {
// Check if user is already logged in on service initialization
this.checkAuthStatus();
}
/**
* Attempt to login with the provided code
*/
public login(code: string): Observable<any> {
return this.loginService.login(code).pipe(
tap(jwt => {
this.saveJwt(jwt as JsonWebKey);
this.isAuthenticatedSubject.next(true);
}),
catchError(error => {
this.snackBar.open('Login failed', '', {duration: 2000});
return throwError(() => error);
})
);
}
/**
* Log the user out by removing the JWT
*/
public logout(): void {
this.cookieService.delete('jwt', '/');
this.isAuthenticatedSubject.next(false);
this.userClaimsSubject.next(null);
}
/**
* Check if the user is authenticated
*/
public checkAuthStatus(): boolean {
const jwt = this.getJwt();
if (jwt) {
try {
const claims = this.extractJwtClaims(jwt as JsonWebKey);
// Check if token is expired
const currentTime = Math.floor(Date.now() / 1000);
if (claims.exp && claims.exp < currentTime) {
this.logout();
return false;
}
this.userClaimsSubject.next(claims);
this.isAuthenticatedSubject.next(true);
return true;
} catch (e) {
this.logout();
}
}
return false;
}
/**
* Get the JWT from cookies
*/
public getJwt(): string | null {
return this.cookieService.check('jwt') ? this.cookieService.get('jwt') : null;
}
/**
* Save the JWT to cookies
*/
private saveJwt(jwt: JsonWebKey): void {
this.cookieService.set('jwt', jwt.toString(), {
path: '/',
secure: true,
sameSite: 'Strict'
});
const claims = this.extractJwtClaims(jwt);
this.userClaimsSubject.next(claims);
}
/**
* Extract claims from JWT
*/
private extractJwtClaims(jwt: JsonWebKey): any {
const token = jwt.toString();
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(window.atob(base64));
}
/**
* Get user authorizations from claims
*/
public getUserAuthorizations(): string[] {
const claims = this.userClaimsSubject.getValue();
return claims?.authorizations || [];
}
}
+2
View File
@@ -1,3 +1,5 @@
@import '@angular/material/prebuilt-themes/azure-blue.css';
:root {
--white: #FFFFFF;
--black: #282828;