diff --git a/client/src/app/site/pages/login/pages/login-mask/components/login-mask/login-mask.component.html b/client/src/app/site/pages/login/pages/login-mask/components/login-mask/login-mask.component.html
new file mode 100644
index 0000000000..aa9f04cc68
--- /dev/null
+++ b/client/src/app/site/pages/login/pages/login-mask/components/login-mask/login-mask.component.html
@@ -0,0 +1,124 @@
+
+
+
+
+
diff --git a/client/src/app/site/pages/login/pages/login-mask/components/login-mask/login-mask.component.scss b/client/src/app/site/pages/login/pages/login-mask/components/login-mask/login-mask.component.scss
new file mode 100644
index 0000000000..07ea4d1e5c
--- /dev/null
+++ b/client/src/app/site/pages/login/pages/login-mask/components/login-mask/login-mask.component.scss
@@ -0,0 +1,54 @@
+mat-form-field {
+ width: 100%;
+}
+
+.forgot-password-button {
+ float: right;
+ padding: 0;
+ text-align: right;
+}
+
+.login-button {
+ margin-top: 30px;
+ height: 37px;
+ width: 100%;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.form-wrapper {
+ padding-left: 30px;
+ padding-right: 30px;
+ mat-spinner {
+ position: absolute;
+ left: 0;
+ right: 0;
+ margin-left: auto;
+ margin-right: auto;
+ z-index: 2;
+ }
+}
+
+.login-container {
+ padding-top: 50px;
+ margin: 0 auto;
+ max-width: 400px;
+
+ mat-icon {
+ cursor: pointer;
+ }
+}
+
+.header-name {
+ padding-top: 50px;
+ text-align: center;
+}
+
+.expansion-button {
+ padding-left: 0;
+}
+
+.expandable-area {
+ padding-bottom: 30px;
+ padding-top: 10px;
+}
diff --git a/client/src/app/site/pages/login/pages/login-mask/components/login-mask/login-mask.component.spec.ts b/client/src/app/site/pages/login/pages/login-mask/components/login-mask/login-mask.component.spec.ts
new file mode 100644
index 0000000000..b37e0b2e3c
--- /dev/null
+++ b/client/src/app/site/pages/login/pages/login-mask/components/login-mask/login-mask.component.spec.ts
@@ -0,0 +1,24 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { LoginMaskComponent } from './login-mask.component';
+
+xdescribe(`LoginMaskComponent`, () => {
+ let component: LoginMaskComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [LoginMaskComponent]
+ }).compileComponents();
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(LoginMaskComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it(`should create`, () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/client/src/app/site/pages/login/pages/login-mask/components/login-mask/login-mask.component.ts b/client/src/app/site/pages/login/pages/login-mask/components/login-mask/login-mask.component.ts
new file mode 100644
index 0000000000..947a4af760
--- /dev/null
+++ b/client/src/app/site/pages/login/pages/login-mask/components/login-mask/login-mask.component.ts
@@ -0,0 +1,248 @@
+import { Component, OnDestroy, OnInit } from '@angular/core';
+import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
+import { ActivatedRoute } from '@angular/router';
+import { marker as _ } from '@colsen1991/ngx-translate-extract-marker';
+import { TranslateService } from '@ngx-translate/core';
+import { filter, Observable, Subscription } from 'rxjs';
+import { fadeInAnim } from 'src/app/infrastructure/animations';
+import { BaseMeetingComponent } from 'src/app/site/pages/meetings/base/base-meeting.component';
+import { ViewMeeting } from 'src/app/site/pages/meetings/view-models/view-meeting';
+import { OrganizationService } from 'src/app/site/pages/organization/services/organization.service';
+import { OrganizationSettingsService } from 'src/app/site/pages/organization/services/organization-settings.service';
+import { ViewOrganization } from 'src/app/site/pages/organization/view-models/view-organization';
+import { AuthService } from 'src/app/site/services/auth.service';
+import { OpenSlidesRouterService } from 'src/app/site/services/openslides-router.service';
+import { OperatorService } from 'src/app/site/services/operator.service';
+import { ParentErrorStateMatcher } from 'src/app/ui/modules/search-selector/validators';
+
+import { BrowserSupportService } from '../../../../services/browser-support.service';
+
+const HTTP_WARNING = _(`Using OpenSlides over HTTP is not supported. Enable HTTPS to continue.`);
+const HTTP_H1_WARNING = _(
+ `Using OpenSlides over HTTP 1.1 or lower is not supported. Make sure you can use HTTP 2 to continue.`
+);
+
+interface LoginValues {
+ username: string;
+ password: string;
+}
+
+@Component({
+ selector: `os-login-mask`,
+ templateUrl: `./login-mask.component.html`,
+ styleUrls: [`./login-mask.component.scss`],
+ animations: [fadeInAnim]
+})
+export class LoginMaskComponent extends BaseMeetingComponent implements OnInit, OnDestroy {
+ public get meetingObservable(): Observable {
+ return this.activeMeetingService.meetingObservable;
+ }
+
+ public get organizationObservable(): Observable {
+ return this.orgaService.organizationObservable;
+ }
+
+ /**
+ * Show or hide password and change the indicator accordingly
+ */
+ public hide = false;
+
+ public loginAreaExpanded = false;
+
+ private checkBrowser = true;
+
+ /**
+ * Reference to the SnackBarEntry for the installation notice send by the server.
+ */
+ public installationNotice = ``;
+
+ /**
+ * Login Error Message if any
+ */
+ public loginErrorMsg = ``;
+
+ /**
+ * Form group for the login form
+ */
+ public loginForm: UntypedFormGroup;
+
+ /**
+ * Custom Form validation
+ */
+ public parentErrorStateMatcher = new ParentErrorStateMatcher();
+
+ public operatorSubscription: Subscription | null = null;
+
+ public samlLoginButtonText: string | null = null;
+
+ public samlEnabled = true;
+
+ public guestsEnabled = false;
+
+ public isWaitingOnLogin = false;
+
+ public loading = true;
+
+ /**
+ * The message, that should appear, when the user logs in.
+ */
+ private loginMessage = `Loading data. Please wait ...`;
+
+ private currentMeetingId: number | null = null;
+
+ public constructor(
+ protected override translate: TranslateService,
+ private authService: AuthService,
+ private operator: OperatorService,
+ private route: ActivatedRoute,
+ private osRouter: OpenSlidesRouterService,
+ private formBuilder: UntypedFormBuilder,
+ private orgaService: OrganizationService,
+ private orgaSettings: OrganizationSettingsService,
+ private browserSupport: BrowserSupportService // private spinnerService: SpinnerService
+ ) {
+ super();
+ // Hide the spinner if the user is at `login-mask`
+ this.loginForm = this.createForm();
+ }
+
+ /**
+ * Init.
+ *
+ * Set the title to "Log In"
+ * Observes the operator, if a user was already logged in, recreate to user and skip the login
+ */
+ public ngOnInit(): void {
+ this.subscriptions.push(
+ this.orgaSettings.get(`login_text`).subscribe(notice => (this.installationNotice = notice))
+ );
+
+ // Maybe the operator changes and the user is logged in. If so, redirect him and boot OpenSlides.
+ this.operatorSubscription = this.operator.operatorUpdated.subscribe(() => {
+ this.clearOperatorSubscription();
+ this.osRouter.navigateAfterLogin(this.currentMeetingId);
+ });
+
+ this.route.queryParams.pipe(filter(params => params[`checkBrowser`])).subscribe(params => {
+ this.checkBrowser = params[`checkBrowser`] === `true`;
+ });
+ this.route.params.subscribe(params => {
+ if (params[`meetingId`]) {
+ this.checkIfGuestsEnabled(params[`meetingId`]);
+ }
+ });
+
+ if (this.checkBrowser) {
+ this.checkDevice();
+ }
+
+ // check if global saml auth is enabled
+ this.subscriptions.push(
+ this.orgaSettings.getSafe(`saml_enabled`).subscribe(enabled => {
+ this.samlEnabled = enabled;
+ this.loading = false;
+ }),
+ this.orgaSettings.get(`saml_login_button_text`).subscribe(text => {
+ this.samlLoginButtonText = text;
+ })
+ );
+
+ this.checkForUnsecureConnection();
+ }
+
+ /**
+ * Clear the subscription on destroy.
+ */
+ public override ngOnDestroy(): void {
+ super.ngOnDestroy();
+ this.clearOperatorSubscription();
+ }
+
+ /**
+ * Actual login function triggered by the form.
+ *
+ * Send username and password to the {@link AuthService}
+ */
+ public async formLogin(): Promise {
+ this.isWaitingOnLogin = true;
+ this.loginErrorMsg = ``;
+ try {
+ // this.spinnerService.show(this.loginMessage, { hideWhenStable: true });
+ const { username, password } = this.formatLoginInputValues(this.loginForm.value);
+ await this.authService.login(username, password);
+ } catch (e: any) {
+ this.isWaitingOnLogin = false;
+ // this.spinnerService.hide();
+ this.loginErrorMsg = `${this.translate.instant(`Error`)}: ${this.translate.instant(e.message)}`;
+ }
+ }
+
+ public async guestLogin(): Promise {
+ this.router.navigate([`${this.currentMeetingId}/`]);
+ }
+
+ public async samlLogin(): Promise {
+ const redirectUrl = await this.authService.startSamlLogin();
+ location.replace(redirectUrl);
+ }
+
+ /**
+ * Go to the reset password view
+ */
+ public resetPassword(): void {
+ this.router.navigate([`./forget-password`], { relativeTo: this.route });
+ }
+
+ public toggleLoginAreaExpansion(): void {
+ this.loginAreaExpanded = !this.loginAreaExpanded;
+ }
+
+ public setLoginAreaExpansion(expanded: boolean): void {
+ this.loginAreaExpanded = expanded;
+ }
+
+ private formatLoginInputValues(info: LoginValues): LoginValues {
+ const newName = info.username.trim();
+ return { username: newName, password: info.password };
+ }
+
+ private checkForUnsecureConnection(): void {
+ const protocol = (performance.getEntriesByType(`navigation`)[0]).nextHopProtocol;
+ if (location.protocol === `http:`) {
+ this.raiseWarning(this.translate.instant(HTTP_WARNING));
+ } else if (protocol && protocol !== `h2` && protocol !== `h3`) {
+ this.raiseWarning(this.translate.instant(HTTP_H1_WARNING));
+ }
+ }
+
+ private checkIfGuestsEnabled(meetingId: string): void {
+ this.currentMeetingId = Number(meetingId);
+ this.meetingSettingsService.get(`enable_anonymous`).subscribe(isEnabled => (this.guestsEnabled = isEnabled));
+ }
+
+ private checkDevice(): void {
+ if (!this.browserSupport.isBrowserSupported()) {
+ this.router.navigate([`./unsupported-browser`], { relativeTo: this.route });
+ }
+ }
+
+ /**
+ * Clears the subscription to the operator.
+ */
+ private clearOperatorSubscription(): void {
+ if (this.operatorSubscription) {
+ this.operatorSubscription.unsubscribe();
+ this.operatorSubscription = null;
+ }
+ }
+
+ /**
+ * Create the login Form
+ */
+ private createForm(): UntypedFormGroup {
+ return this.formBuilder.group({
+ username: [``, [Validators.required, Validators.maxLength(128)]],
+ password: [``, [Validators.required, Validators.maxLength(128)]]
+ });
+ }
+}
diff --git a/client/src/app/site/pages/login/pages/login-mask/login-mask-routing.module.ts b/client/src/app/site/pages/login/pages/login-mask/login-mask-routing.module.ts
new file mode 100644
index 0000000000..fe84f733ce
--- /dev/null
+++ b/client/src/app/site/pages/login/pages/login-mask/login-mask-routing.module.ts
@@ -0,0 +1,18 @@
+import { NgModule } from '@angular/core';
+import { RouterModule, Routes } from '@angular/router';
+
+import { LoginMaskComponent } from './components/login-mask/login-mask.component';
+
+const routes: Routes = [
+ {
+ path: ``,
+ pathMatch: `full`,
+ component: LoginMaskComponent
+ }
+];
+
+@NgModule({
+ imports: [RouterModule.forChild(routes)],
+ exports: [RouterModule]
+})
+export class LoginMaskRoutingModule {}
diff --git a/client/src/app/site/pages/login/pages/login-mask/login-mask.module.ts b/client/src/app/site/pages/login/pages/login-mask/login-mask.module.ts
new file mode 100644
index 0000000000..d46e129b18
--- /dev/null
+++ b/client/src/app/site/pages/login/pages/login-mask/login-mask.module.ts
@@ -0,0 +1,34 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { ReactiveFormsModule } from '@angular/forms';
+import { MatButtonModule } from '@angular/material/button';
+import { MatCardModule } from '@angular/material/card';
+import { MatExpansionModule } from '@angular/material/expansion';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatIconModule } from '@angular/material/icon';
+import { MatInputModule } from '@angular/material/input';
+import { OpenSlidesTranslationModule } from 'src/app/site/modules/translations';
+import { DirectivesModule } from 'src/app/ui/directives';
+import { SpinnerModule } from 'src/app/ui/modules/spinner';
+
+import { LoginMaskComponent } from './components/login-mask/login-mask.component';
+import { LoginMaskRoutingModule } from './login-mask-routing.module';
+
+@NgModule({
+ declarations: [LoginMaskComponent],
+ imports: [
+ CommonModule,
+ LoginMaskRoutingModule,
+ DirectivesModule,
+ MatCardModule,
+ MatFormFieldModule,
+ MatInputModule,
+ MatIconModule,
+ MatButtonModule,
+ SpinnerModule,
+ MatExpansionModule,
+ ReactiveFormsModule,
+ OpenSlidesTranslationModule.forChild()
+ ]
+})
+export class LoginMaskModule {}