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 @@ +
+ @if ({ meeting: meetingObservable | async, organization: organizationObservable | async }; as observables) { + @if (observables.meeting || observables.organization) { +
+ + @if (observables.meeting) { +

{{ observables.meeting.name }}

+ } + + @if (observables.organization && !observables.meeting) { +

+ {{ observables.organization.name }} +

+ } +
+ } + } + + + @if (installationNotice) { +
+ + + +
+ } + @if (!samlEnabled && !loading) { + + } + @if (samlEnabled && !loading) { +
+ @if (samlEnabled) { + + } +
+ @if (guestsEnabled) { + + } +
+ + + + {{ 'Internal login' | translate }} + + + @if (loginAreaExpanded) { +
+ +
+ } +
+
+ } +
+ + +
+ + {{ 'Username' | translate }} + + +
+ + {{ 'Password' | translate }} + + + {{ hide ? 'visibility_off' : 'visibility_on' }} + + + {{ loginErrorMsg | translate }} + + +
+ + + @if (guestsEnabled && showExtra) { + + } + + +
+ +
+
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 {}