diff --git a/gravitee-apim-portal-webui-next/package.json b/gravitee-apim-portal-webui-next/package.json index 1cc157abcd8..c0bceaba749 100644 --- a/gravitee-apim-portal-webui-next/package.json +++ b/gravitee-apim-portal-webui-next/package.json @@ -61,6 +61,7 @@ "@fontsource/roboto": "5.2.5", "@types/swagger-ui": "3.52.4", "angular-gridster2": "20.2.3", + "angular-oauth2-oidc": "20.0.2", "asciidoctor": "3.0.4", "chart.js": "4.3.0", "dompurify": "3.2.7", diff --git a/gravitee-apim-portal-webui-next/src/app/app.component.html b/gravitee-apim-portal-webui-next/src/app/app.component.html index 2f3f7d95e10..40f4d073c64 100644 --- a/gravitee-apim-portal-webui-next/src/app/app.component.html +++ b/gravitee-apim-portal-webui-next/src/app/app.component.html @@ -15,7 +15,12 @@ limitations under the License. --> - +
diff --git a/gravitee-apim-portal-webui-next/src/app/app.component.ts b/gravitee-apim-portal-webui-next/src/app/app.component.ts index ce882154f2a..141d1c2f17f 100644 --- a/gravitee-apim-portal-webui-next/src/app/app.component.ts +++ b/gravitee-apim-portal-webui-next/src/app/app.component.ts @@ -53,6 +53,10 @@ export class AppComponent { }); } + forceLogin(): boolean { + return this.configService.configuration.authentication?.forceLogin?.enabled ?? false; + } + private updateFavicon() { let link: HTMLLinkElement | null = document.querySelector("link[rel~='icon']"); if (!link) { diff --git a/gravitee-apim-portal-webui-next/src/app/app.config.ts b/gravitee-apim-portal-webui-next/src/app/app.config.ts index 8cde9467ba5..980cd6b80de 100644 --- a/gravitee-apim-portal-webui-next/src/app/app.config.ts +++ b/gravitee-apim-portal-webui-next/src/app/app.config.ts @@ -19,6 +19,7 @@ import { ApplicationConfig, inject, provideAppInitializer } from '@angular/core' import { MAT_RIPPLE_GLOBAL_OPTIONS } from '@angular/material/core'; import { provideAnimations } from '@angular/platform-browser/animations'; import { provideRouter, Router, withComponentInputBinding, withRouterConfig } from '@angular/router'; +import { provideOAuthClient } from 'angular-oauth2-oidc'; import { provideCharts, withDefaultRegisterables } from 'ng2-charts'; import { catchError, combineLatest, Observable, switchMap } from 'rxjs'; import { of } from 'rxjs/internal/observable/of'; @@ -26,12 +27,14 @@ import { of } from 'rxjs/internal/observable/of'; import { routes } from './app.routes'; import { csrfInterceptor } from '../interceptors/csrf.interceptor'; import { httpRequestInterceptor } from '../interceptors/http-request.interceptor'; +import { AuthService } from '../services/auth.service'; import { ConfigService } from '../services/config.service'; import { CurrentUserService } from '../services/current-user.service'; import { PortalMenuLinksService } from '../services/portal-menu-links.service'; import { ThemeService } from '../services/theme.service'; function initApp( + authService: AuthService, configService: ConfigService, themeService: ThemeService, currentUserService: CurrentUserService, @@ -43,9 +46,9 @@ function initApp( switchMap(_ => combineLatest([ themeService.loadTheme(), - currentUserService.loadUser(), configService.loadConfiguration(), portalMenuLinksService.loadCustomLinks(), + authService.load().pipe(switchMap(_ => currentUserService.loadUser())), ]), ), catchError(error => { @@ -60,8 +63,10 @@ export const appConfig: ApplicationConfig = { provideRouter(routes, withComponentInputBinding(), withRouterConfig({ paramsInheritanceStrategy: 'always' })), provideHttpClient(withInterceptors([httpRequestInterceptor, csrfInterceptor])), provideAnimations(), + provideOAuthClient(), provideAppInitializer(() => { const initializerFn = initApp( + inject(AuthService), inject(ConfigService), inject(ThemeService), inject(CurrentUserService), diff --git a/gravitee-apim-portal-webui-next/src/app/app.routes.ts b/gravitee-apim-portal-webui-next/src/app/app.routes.ts index 9d49b9cf5be..ff634f9d467 100644 --- a/gravitee-apim-portal-webui-next/src/app/app.routes.ts +++ b/gravitee-apim-portal-webui-next/src/app/app.routes.ts @@ -131,13 +131,13 @@ const apiRoutes: Routes = [ export const routes: Routes = [ { path: '', - canActivate: [redirectGuard], + canActivate: [redirectGuard, authGuard], resolve: { homepage: homepageResolver }, component: HomepageComponent, }, { path: 'catalog', - canActivateChild: [redirectGuard], + canActivateChild: [redirectGuard, authGuard], data: { breadcrumb: 'Catalog' }, children: [ { @@ -178,7 +178,7 @@ export const routes: Routes = [ }, { path: 'applications', - canActivateChild: [authGuard, redirectGuard], + canActivateChild: [redirectGuard, authGuard], children: [ { path: '', component: ApplicationsComponent, data: { breadcrumb: 'Applications' } }, { @@ -220,6 +220,7 @@ export const routes: Routes = [ }, { path: 'guides', + canActivate: [redirectGuard, authGuard], component: GuidesComponent, data: { breadcrumb: { label: 'Documentation', disable: true } }, resolve: { pages: environmentPagesResolver }, @@ -252,7 +253,7 @@ export const routes: Routes = [ }, ], }, - { path: 'log-out', component: LogOutComponent, canActivate: [redirectGuard, authGuard] }, + { path: 'log-out', component: LogOutComponent, canActivate: [redirectGuard] }, { path: '404', component: NotFoundComponent }, { path: '503', component: ServiceUnavailableComponent }, { path: 'gravitee-md', component: GraviteeMarkdownComponent }, diff --git a/gravitee-apim-portal-webui-next/src/app/log-in/log-in.component.html b/gravitee-apim-portal-webui-next/src/app/log-in/log-in.component.html index a87f07241c7..82c8ad11137 100644 --- a/gravitee-apim-portal-webui-next/src/app/log-in/log-in.component.html +++ b/gravitee-apim-portal-webui-next/src/app/log-in/log-in.component.html @@ -15,43 +15,60 @@ limitations under the License. --> - diff --git a/gravitee-apim-portal-webui-next/src/app/log-in/log-in.component.scss b/gravitee-apim-portal-webui-next/src/app/log-in/log-in.component.scss index b3a4593db51..1a648b2036a 100644 --- a/gravitee-apim-portal-webui-next/src/app/log-in/log-in.component.scss +++ b/gravitee-apim-portal-webui-next/src/app/log-in/log-in.component.scss @@ -13,6 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@use '../../scss/theme'; + :host { display: flex; justify-content: center; @@ -21,21 +23,34 @@ .log-in { display: flex; flex-flow: column; - gap: 32px; + gap: 20px; &__form { display: flex; flex-flow: column; - gap: 12px; + gap: 16px; + + &__header { + padding: 20px 20px 0; + } &__container { - width: 100%; - max-width: 500px; + width: 400px; + border-radius: 8px; + + &--mobile { + max-width: 75vw; + } + } + + &__content:last-child { + padding: 0 20px 20px; } &__fields { display: flex; flex-flow: column; + gap: 8px; } &__buttons { @@ -47,5 +62,67 @@ &__submit { width: 100%; } + + &__forgot-password { + margin-top: 5px; + color: #{theme.$primary-main-color}; + text-align: center; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + &__error { + text-align: center; + } + } + + &__or-separator { + position: relative; + display: flex; + height: 30px; + align-items: center; + margin: 20px 0; + + &__line { + width: 100%; + border-top: 1px solid #{theme.$card-border-color}; + } + + &__label { + position: absolute; + display: flex; + width: 40px; + height: 100%; + align-items: center; + justify-content: center; + margin: auto; + background: #{theme.$card-background-color}; + inset: 0; + } + } + + &__sso-provider { + width: 100%; + padding: 0 18px; + margin-bottom: 16px; + color: #{theme.$button-text-text-color}; + + &:last-of-type { + margin-bottom: 0; + } + + &__container { + display: flex; + align-items: center; + gap: 7px; + } + + &__logo { + width: 23px; + height: 23px; + } } } diff --git a/gravitee-apim-portal-webui-next/src/app/log-in/log-in.component.spec.ts b/gravitee-apim-portal-webui-next/src/app/log-in/log-in.component.spec.ts index a00955d20c6..67002e6f122 100644 --- a/gravitee-apim-portal-webui-next/src/app/log-in/log-in.component.spec.ts +++ b/gravitee-apim-portal-webui-next/src/app/log-in/log-in.component.spec.ts @@ -22,29 +22,59 @@ import { MatButtonHarness } from '@angular/material/button/testing'; import { MatInputHarness } from '@angular/material/input/testing'; import { LogInComponent } from './log-in.component'; -import { AppTestingModule, TESTING_BASE_URL } from '../../testing/app-testing.module'; +import { IdentityProvider } from '../../entities/configuration/identity-provider'; +import { AuthService } from '../../services/auth.service'; +import { ConfigService } from '../../services/config.service'; +import { IdentityProviderService } from '../../services/identity-provider.service'; +import { AppTestingModule, ConfigServiceStub, IdentityProviderServiceStub, TESTING_BASE_URL } from '../../testing/app-testing.module'; +import { DivHarness } from '../../testing/div.harness'; describe('LogInComponent', () => { let fixture: ComponentFixture; let harnessLoader: HarnessLoader; let httpTestingController: HttpTestingController; - beforeEach(async () => { + const init = async ( + params: Partial<{ enableLocalLogin: boolean; ssoProviders: IdentityProvider[] }> = { + enableLocalLogin: true, + ssoProviders: [], + }, + ) => { await TestBed.configureTestingModule({ imports: [LogInComponent, AppTestingModule], + providers: [ + { + provide: ConfigService, + useFactory: () => { + const stub = new ConfigServiceStub(); + stub.configuration.authentication!.localLogin!.enabled = params.enableLocalLogin; + return stub; + }, + }, + { + provide: IdentityProviderService, + useFactory: () => { + const stub = new IdentityProviderServiceStub(); + stub.providers = params.ssoProviders!; + return stub; + }, + }, + ], }).compileComponents(); fixture = TestBed.createComponent(LogInComponent); harnessLoader = TestbedHarnessEnvironment.loader(fixture); httpTestingController = TestBed.inject(HttpTestingController); fixture.detectChanges(); - }); + }; afterEach(() => { httpTestingController.verify(); }); it('should allow submit if username and password are valid', async () => { + await init(); + const submitButton = await harnessLoader.getHarness(MatButtonHarness.with({ text: 'Log in' })); const username = await harnessLoader.getHarness(MatInputHarness.with({ selector: '[formControlName="username"]' })); @@ -59,6 +89,8 @@ describe('LogInComponent', () => { }); it('should not validate form with missing username', async () => { + await init(); + const submitButton = await harnessLoader.getHarness(MatButtonHarness.with({ text: 'Log in' })); const password = await harnessLoader.getHarness(MatInputHarness.with({ selector: '[formControlName="password"]' })); @@ -66,7 +98,10 @@ describe('LogInComponent', () => { expect(await submitButton.isDisabled()).toEqual(true); }); + it('should not validate form with missing password', async () => { + await init(); + const submitButton = await harnessLoader.getHarness(MatButtonHarness.with({ text: 'Log in' })); const username = await harnessLoader.getHarness(MatInputHarness.with({ selector: '[formControlName="username"]' })); @@ -76,6 +111,8 @@ describe('LogInComponent', () => { }); it('should login and fetch current user on submit', async () => { + await init(); + const submitButton = await harnessLoader.getHarness(MatButtonHarness.with({ text: 'Log in' })); const username = await harnessLoader.getHarness(MatInputHarness.with({ selector: '[formControlName="username"]' })); await username.setValue('john@doe.com'); @@ -88,4 +125,79 @@ describe('LogInComponent', () => { httpTestingController.expectOne(`${TESTING_BASE_URL}/user`).flush({}); httpTestingController.expectOne(`${TESTING_BASE_URL}/portal-menu-links`).flush({}); }); + + it('should not display log-in form', async () => { + await init({ + enableLocalLogin: false, + ssoProviders: [ + { + id: 'github', + name: 'GitHub', + }, + ], + }); + + const login = await harnessLoader.getHarnessOrNull(DivHarness.with({ selector: '.log-in__form' })); + expect(login).toBeNull(); + + const orSeparator = await harnessLoader.getHarnessOrNull(DivHarness.with({ selector: '.log-in__or-separator' })); + expect(orSeparator).toBeNull(); + + const ssoProvider = await harnessLoader.getHarnessOrNull(MatButtonHarness.with({ selector: '.log-in__sso-provider' })); + expect(ssoProvider).not.toBeNull(); + + const providerText = await ssoProvider!.getText(); + expect(providerText).toEqual('Continue with GitHub'); + }); + + it('should not display SSO providers', async () => { + await init({ + enableLocalLogin: true, + }); + + const login = await harnessLoader.getHarnessOrNull(DivHarness.with({ selector: '.log-in__form' })); + expect(login).not.toBeNull(); + + const orSeparator = await harnessLoader.getHarnessOrNull(DivHarness.with({ selector: '.log-in__or-separator' })); + expect(orSeparator).toBeNull(); + + const ssoProvider = await harnessLoader.getHarnessOrNull(MatButtonHarness.with({ selector: '.log-in__sso-provider' })); + expect(ssoProvider).toBeNull(); + }); + + it('should display "or" separator and identity providers', async () => { + const identityProviders = [ + { id: 'github', name: 'GitHub' }, + { id: 'google', name: 'Google' }, + { id: 'graviteeio_am', name: 'Gravitee AM' }, + ]; + await init({ + enableLocalLogin: true, + ssoProviders: identityProviders, + }); + + const orSeparator = await harnessLoader.getHarnessOrNull(DivHarness.with({ selector: '.log-in__or-separator' })); + expect(orSeparator).not.toBeNull(); + + const ssoProviders = await harnessLoader.getAllHarnesses(MatButtonHarness.with({ selector: '.log-in__sso-provider' })); + const providerTexts = await Promise.all(ssoProviders.map(harness => harness.getText())); + console.log('providerTexts', providerTexts); + + for (const provider of identityProviders) { + const found = providerTexts.find(text => text === `Continue with ${provider.name}`); + expect(found).toBeDefined(); + } + }); + + it('should redirect when clicked on SSO provider', async () => { + await init({ + enableLocalLogin: true, + ssoProviders: [{ id: 'google', name: 'Google' }], + }); + const authenticateSSO = jest.spyOn(TestBed.inject(AuthService), 'authenticateSSO').mockReturnValue(); + + const ssoProvider = await harnessLoader.getHarness(MatButtonHarness.with({ selector: '.log-in__sso-provider' })); + await ssoProvider.click(); + expect(authenticateSSO).toHaveBeenCalledWith({ id: 'google', name: 'Google' }, ''); + }); }); diff --git a/gravitee-apim-portal-webui-next/src/app/log-in/log-in.component.ts b/gravitee-apim-portal-webui-next/src/app/log-in/log-in.component.ts index afaa7ca1261..d6dc5310727 100644 --- a/gravitee-apim-portal-webui-next/src/app/log-in/log-in.component.ts +++ b/gravitee-apim-portal-webui-next/src/app/log-in/log-in.component.ts @@ -13,24 +13,43 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { AsyncPipe } from '@angular/common'; import { HttpErrorResponse } from '@angular/common/http'; import { Component, DestroyRef, inject, signal } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; -import { MatAnchor, MatButtonModule } from '@angular/material/button'; +import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatError, MatFormField, MatLabel } from '@angular/material/form-field'; import { MatInput } from '@angular/material/input'; -import { Router, RouterLink } from '@angular/router'; -import { switchMap, tap } from 'rxjs'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { OAuthModule } from 'angular-oauth2-oidc'; +import { map, switchMap, tap } from 'rxjs'; +import { MobileClassDirective } from '../../directives/mobile-class.directive'; +import { IdentityProvider } from '../../entities/configuration/identity-provider'; +import { IdentityProviderType } from '../../entities/configuration/identity-provider-type'; import { AuthService } from '../../services/auth.service'; +import { ConfigService } from '../../services/config.service'; import { CurrentUserService } from '../../services/current-user.service'; +import { IdentityProviderService } from '../../services/identity-provider.service'; import { PortalMenuLinksService } from '../../services/portal-menu-links.service'; @Component({ selector: 'app-log-in', - imports: [MatCardModule, MatFormField, MatInput, MatButtonModule, MatLabel, ReactiveFormsModule, MatError, RouterLink, MatAnchor], + imports: [ + MatCardModule, + MatFormField, + MatInput, + MatButtonModule, + MatLabel, + ReactiveFormsModule, + MatError, + RouterLink, + OAuthModule, + AsyncPipe, + MobileClassDirective, + ], templateUrl: './log-in.component.html', styleUrl: './log-in.component.scss', }) @@ -40,13 +59,18 @@ export class LogInComponent { password: new FormControl('', [Validators.required]), }); error = signal(200); + isLocalLoginEnabled = inject(ConfigService).configuration.authentication?.localLogin?.enabled ?? false; + identityProviders$ = inject(IdentityProviderService) + .getPortalIdentityProviders() + .pipe(map(({ data }) => data ?? [])); + private readonly redirectUrl = toSignal(inject(ActivatedRoute).queryParams.pipe(map(params => params['redirectUrl'] || ''))); - private destroyRef = inject(DestroyRef); constructor( - private authService: AuthService, - private currentUserService: CurrentUserService, - private portalMenuLinksService: PortalMenuLinksService, - private router: Router, + private readonly authService: AuthService, + private readonly currentUserService: CurrentUserService, + private readonly portalMenuLinksService: PortalMenuLinksService, + private readonly router: Router, + private readonly destroyRef: DestroyRef, ) {} logIn() { @@ -55,7 +79,7 @@ export class LogInComponent { .pipe( switchMap(_ => this.currentUserService.loadUser()), switchMap(_ => this.portalMenuLinksService.loadCustomLinks()), - tap(_ => this.router.navigate([''])), + tap(_ => this.router.navigate([this.redirectUrl()])), takeUntilDestroyed(this.destroyRef), ) .subscribe({ @@ -64,4 +88,13 @@ export class LogInComponent { }, }); } + + authenticateSSO(provider: IdentityProvider) { + this.authService.authenticateSSO(provider, this.redirectUrl()); + } + + getProviderLogo(provider: IdentityProvider) { + const type = provider.type ?? IdentityProviderType.OIDC; + return `${type.toLowerCase()}.svg`; + } } diff --git a/gravitee-apim-portal-webui-next/src/app/log-in/reset-password/reset-password-confirmation/reset-password-confirmation.component.html b/gravitee-apim-portal-webui-next/src/app/log-in/reset-password/reset-password-confirmation/reset-password-confirmation.component.html index 2987c453d52..7ca9f7ba9ab 100644 --- a/gravitee-apim-portal-webui-next/src/app/log-in/reset-password/reset-password-confirmation/reset-password-confirmation.component.html +++ b/gravitee-apim-portal-webui-next/src/app/log-in/reset-password/reset-password-confirmation/reset-password-confirmation.component.html @@ -15,13 +15,15 @@ limitations under the License. --> - + @if (!isSubmitted) { - - Reset password confirmation + + Reset password confirmation @if (!isTokenExpired && !!resetPasswordConfirmationForm) { - +
@@ -56,7 +58,7 @@ type="submit" mat-flat-button i18n="@@resetPasswordConfirmationAction" - class="reset-password-confirmation__form__submit secondary-button"> + class="reset-password-confirmation__form__submit"> Reset password
@@ -74,8 +76,8 @@ } } @if (isSubmitted) { - - Password reset + + Password reset diff --git a/gravitee-apim-portal-webui-next/src/app/log-in/reset-password/reset-password-confirmation/reset-password-confirmation.component.scss b/gravitee-apim-portal-webui-next/src/app/log-in/reset-password/reset-password-confirmation/reset-password-confirmation.component.scss index 294c8498135..c6669092430 100644 --- a/gravitee-apim-portal-webui-next/src/app/log-in/reset-password/reset-password-confirmation/reset-password-confirmation.component.scss +++ b/gravitee-apim-portal-webui-next/src/app/log-in/reset-password/reset-password-confirmation/reset-password-confirmation.component.scss @@ -13,6 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@use '../../../../scss/theme'; + :host { display: flex; justify-content: center; @@ -20,20 +22,32 @@ .reset-password-confirmation { display: flex; - width: 500px; + width: 400px; flex-flow: column; - gap: 32px; + gap: 20px; + + &--mobile { + max-width: 75vw; + } - &__form, + &__header { + padding: 20px 20px 0; + } + + &__content, &__expired { display: flex; flex-flow: column; - gap: 12px; + gap: 24px; + + &:last-child { + padding: 0 20px 20px; + } - &__fields, &__text { display: flex; flex-flow: column; + gap: 8px; } &__buttons { @@ -46,4 +60,22 @@ width: 100%; } } + + &__form { + display: flex; + flex-flow: column; + gap: 16px; + + &__fields { + display: flex; + flex-flow: column; + gap: 8px; + } + + &__buttons { + display: flex; + flex-flow: column; + gap: 16px; + } + } } diff --git a/gravitee-apim-portal-webui-next/src/app/log-in/reset-password/reset-password-confirmation/reset-password-confirmation.component.ts b/gravitee-apim-portal-webui-next/src/app/log-in/reset-password/reset-password-confirmation/reset-password-confirmation.component.ts index 13413b4ff48..d57215b70c1 100644 --- a/gravitee-apim-portal-webui-next/src/app/log-in/reset-password/reset-password-confirmation/reset-password-confirmation.component.ts +++ b/gravitee-apim-portal-webui-next/src/app/log-in/reset-password/reset-password-confirmation/reset-password-confirmation.component.ts @@ -23,6 +23,7 @@ import { MatError, MatFormField, MatLabel } from '@angular/material/form-field'; import { MatInput } from '@angular/material/input'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { MobileClassDirective } from '../../../../directives/mobile-class.directive'; import { ResetPasswordService } from '../../../../services/reset-password.service'; import { TokenService } from '../../../../services/token.service'; import { passwordMatchValidator } from '../../../validators/password-match.validator'; @@ -52,6 +53,7 @@ export interface UserFromToken { MatButton, RouterLink, MatAnchor, + MobileClassDirective, ], templateUrl: './reset-password-confirmation.component.html', styleUrl: './reset-password-confirmation.component.scss', @@ -72,7 +74,7 @@ export class ResetPasswordConfirmationComponent implements OnInit { { validators: passwordMatchValidator('password', 'confirmedPassword') }, ); error = signal(200); - private destroyRef = inject(DestroyRef); + private readonly destroyRef = inject(DestroyRef); constructor( private route: ActivatedRoute, diff --git a/gravitee-apim-portal-webui-next/src/app/log-in/reset-password/reset-password.component.html b/gravitee-apim-portal-webui-next/src/app/log-in/reset-password/reset-password.component.html index 6c053709745..d61abc38670 100644 --- a/gravitee-apim-portal-webui-next/src/app/log-in/reset-password/reset-password.component.html +++ b/gravitee-apim-portal-webui-next/src/app/log-in/reset-password/reset-password.component.html @@ -16,10 +16,10 @@ --> - + @if (!isSubmitted) { - - Reset password + + Reset password @@ -42,15 +42,15 @@ type="submit" mat-flat-button i18n="@@resetPasswordAction" - class="reset-password__form__submit secondary-button"> + class="reset-password__form__submit"> Reset password - Log in + Back to Login } @else { - - Password reset confirmed + + Password reset confirmed

If the account exists, an email will be sent.

diff --git a/gravitee-apim-portal-webui-next/src/app/log-in/reset-password/reset-password.component.scss b/gravitee-apim-portal-webui-next/src/app/log-in/reset-password/reset-password.component.scss index f5d8abc47ed..848e05c1879 100644 --- a/gravitee-apim-portal-webui-next/src/app/log-in/reset-password/reset-password.component.scss +++ b/gravitee-apim-portal-webui-next/src/app/log-in/reset-password/reset-password.component.scss @@ -13,6 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@use '../../../scss/theme'; + :host { display: flex; justify-content: center; @@ -20,15 +22,27 @@ .reset-password { display: flex; - width: 500px; + width: 400px; flex-flow: column; - gap: 32px; + gap: 20px; + + &--mobile { + max-width: 75vw; + } &__form { display: flex; flex-flow: column; gap: 12px; + &:last-child { + padding: 0 20px 20px; + } + + &__header { + padding: 20px 20px 0; + } + &__fields { display: flex; flex-flow: column; diff --git a/gravitee-apim-portal-webui-next/src/app/log-in/reset-password/reset-password.component.ts b/gravitee-apim-portal-webui-next/src/app/log-in/reset-password/reset-password.component.ts index 7ab0f2dcc12..09742036124 100644 --- a/gravitee-apim-portal-webui-next/src/app/log-in/reset-password/reset-password.component.ts +++ b/gravitee-apim-portal-webui-next/src/app/log-in/reset-password/reset-password.component.ts @@ -23,6 +23,7 @@ import { MatError, MatFormField, MatLabel } from '@angular/material/form-field'; import { MatInput } from '@angular/material/input'; import { RouterLink } from '@angular/router'; +import { MobileClassDirective } from '../../../directives/mobile-class.directive'; import { ResetPasswordService } from '../../../services/reset-password.service'; @Component({ @@ -41,6 +42,7 @@ import { ResetPasswordService } from '../../../services/reset-password.service'; ReactiveFormsModule, RouterLink, MatAnchor, + MobileClassDirective, ], templateUrl: './reset-password.component.html', styleUrls: ['./reset-password.component.scss'], @@ -51,7 +53,7 @@ export class ResetPasswordComponent { }); isSubmitted: boolean; error = signal(200); - private destroyRef = inject(DestroyRef); + private readonly destroyRef = inject(DestroyRef); constructor(private resetPasswordService: ResetPasswordService) { this.isSubmitted = false; diff --git a/gravitee-apim-portal-webui-next/src/app/log-out/log-out.component.ts b/gravitee-apim-portal-webui-next/src/app/log-out/log-out.component.ts index 6c5ae682c3e..ac66b444ebf 100644 --- a/gravitee-apim-portal-webui-next/src/app/log-out/log-out.component.ts +++ b/gravitee-apim-portal-webui-next/src/app/log-out/log-out.component.ts @@ -28,24 +28,28 @@ import { PortalMenuLinksService } from '../../services/portal-menu-links.service template: '', }) export class LogOutComponent implements OnInit { - private destroyRef = inject(DestroyRef); + private readonly destroyRef = inject(DestroyRef); constructor( - private authService: AuthService, - private currentUserService: CurrentUserService, - private portalMenuLinksService: PortalMenuLinksService, - private router: Router, + private readonly authService: AuthService, + private readonly currentUserService: CurrentUserService, + private readonly portalMenuLinksService: PortalMenuLinksService, + private readonly router: Router, ) {} ngOnInit() { - this.authService - .logout() - .pipe( - tap(_ => this.currentUserService.clear()), - switchMap(_ => this.portalMenuLinksService.loadCustomLinks()), - tap(_ => this.router.navigate([''])), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe(); + if (this.currentUserService.isAuthenticated()) { + this.authService + .logout() + .pipe( + tap(_ => this.currentUserService.clear()), + switchMap(_ => this.portalMenuLinksService.loadCustomLinks()), + tap(_ => this.router.navigate([''])), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(); + } else { + this.router.navigate(['']); + } } } diff --git a/gravitee-apim-portal-webui-next/src/assets/images/idp/github.svg b/gravitee-apim-portal-webui-next/src/assets/images/idp/github.svg new file mode 100644 index 00000000000..a8d1174049a --- /dev/null +++ b/gravitee-apim-portal-webui-next/src/assets/images/idp/github.svg @@ -0,0 +1,3 @@ + + + diff --git a/gravitee-apim-portal-webui-next/src/assets/images/idp/google.svg b/gravitee-apim-portal-webui-next/src/assets/images/idp/google.svg new file mode 100644 index 00000000000..088288fa3fb --- /dev/null +++ b/gravitee-apim-portal-webui-next/src/assets/images/idp/google.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gravitee-apim-portal-webui-next/src/assets/images/idp/graviteeio_am.svg b/gravitee-apim-portal-webui-next/src/assets/images/idp/graviteeio_am.svg new file mode 100644 index 00000000000..b26a8538d5e --- /dev/null +++ b/gravitee-apim-portal-webui-next/src/assets/images/idp/graviteeio_am.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/gravitee-apim-portal-webui-next/src/assets/images/idp/oidc.svg b/gravitee-apim-portal-webui-next/src/assets/images/idp/oidc.svg new file mode 100644 index 00000000000..a2a222efe99 --- /dev/null +++ b/gravitee-apim-portal-webui-next/src/assets/images/idp/oidc.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/gravitee-apim-portal-webui-next/src/components/nav-bar/desktop-nav-bar/desktop-nav-bar.component.html b/gravitee-apim-portal-webui-next/src/components/nav-bar/desktop-nav-bar/desktop-nav-bar.component.html index c870b5f4f88..e1eb302728f 100644 --- a/gravitee-apim-portal-webui-next/src/components/nav-bar/desktop-nav-bar/desktop-nav-bar.component.html +++ b/gravitee-apim-portal-webui-next/src/components/nav-bar/desktop-nav-bar/desktop-nav-bar.component.html @@ -1,13 +1,13 @@ diff --git a/gravitee-apim-portal-webui-next/src/components/nav-bar/nav-bar.component.spec.ts b/gravitee-apim-portal-webui-next/src/components/nav-bar/nav-bar.component.spec.ts index 690fc677eef..027dd638336 100644 --- a/gravitee-apim-portal-webui-next/src/components/nav-bar/nav-bar.component.spec.ts +++ b/gravitee-apim-portal-webui-next/src/components/nav-bar/nav-bar.component.spec.ts @@ -34,6 +34,22 @@ describe('NavBarComponent', () => { let harnessLoader: HarnessLoader; let componentRef: ComponentRef; let httpTestingController: HttpTestingController; + const customLinks = [ + { + id: 'link-id-1', + type: 'external', + name: 'link-name-1', + target: 'link-target-1', + order: 1, + }, + { + id: 'link-id-2', + type: 'external', + name: 'link-name-2', + target: 'link-target-2', + order: 2, + }, + ]; const init = async (isMobile: boolean = false) => { const mockBreakpointObserver = { @@ -69,24 +85,26 @@ describe('NavBarComponent', () => { expect(logInButton).toBeFalsy(); }); - it('should show custom links', async () => { - const customLinks = [ - { - id: 'link-id-1', - type: 'external', - name: 'link-name-1', - target: 'link-target-1', - order: 1, - }, - { - id: 'link-id-2', - type: 'external', - name: 'link-name-2', - target: 'link-target-2', - order: 2, - }, - ]; + it('should not show links if user is not connected and login is forced', async () => { + componentRef.setInput('customLinks', customLinks); + componentRef.setInput('forceLogin', true); + const link1Anchor = await harnessLoader.getHarnessOrNull(MatButtonHarness.with({ text: 'link-name-1' })); + expect(link1Anchor).not.toBeTruthy(); + const link2Anchor = await harnessLoader.getHarnessOrNull(MatButtonHarness.with({ text: 'link-name-2' })); + expect(link2Anchor).not.toBeTruthy(); + }); + it('should show links if user is connected and login is forced', async () => { + componentRef.setInput('customLinks', customLinks); + componentRef.setInput('forceLogin', true); + componentRef.setInput('currentUser', fakeUser()); + const link1Anchor = await harnessLoader.getHarnessOrNull(MatButtonHarness.with({ text: 'link-name-1' })); + expect(link1Anchor).toBeTruthy(); + const link2Anchor = await harnessLoader.getHarnessOrNull(MatButtonHarness.with({ text: 'link-name-2' })); + expect(link2Anchor).toBeTruthy(); + }); + + it('should show custom links if login is not forced', async () => { componentRef.setInput('customLinks', customLinks); const link1Anchor = await harnessLoader.getHarnessOrNull(MatButtonHarness.with({ text: 'link-name-1' })); expect(link1Anchor).toBeTruthy(); @@ -116,27 +134,34 @@ describe('NavBarComponent', () => { expect(linkTexts).toEqual(['Homepage', 'Catalog', 'Guides', 'Sign in']); }); - it('should show custom links', async () => { - expectHomePage([]); + it('should show logout button if user connected', async () => { + expectHomePage(); componentRef.setInput('currentUser', fakeUser()); + fixture.detectChanges(); + + const menuButton = await harnessLoader.getHarness(MatButtonHarness.with({ selector: '.mobile-menu__button' })); + await menuButton.click(); + + const links: NodeList = fixture.debugElement.nativeElement.querySelectorAll('.mobile-menu__link'); + const linkTexts = Array.from(links).map((el: Node) => el.textContent?.trim()); + expect(linkTexts).toEqual(['Homepage', 'Catalog', 'Guides', 'Applications', 'Log out']); + }); - const customLinks = [ - { - id: 'link-id-1', - type: 'external', - name: 'link-name-1', - target: 'link-target-1', - order: 1, - }, - { - id: 'link-id-2', - type: 'external', - name: 'link-name-2', - target: 'link-target-2', - order: 2, - }, - ]; + it('should not show menu if user is not connected and login is forced', async () => { + expectHomePage([]); componentRef.setInput('customLinks', customLinks); + componentRef.setInput('forceLogin', true); + fixture.detectChanges(); + + const menuButton = await harnessLoader.getHarnessOrNull(MatButtonHarness.with({ selector: '.mobile-menu__button' })); + expect(menuButton).not.toBeTruthy(); + }); + + it('should show links if user is connected and login is forced', async () => { + expectHomePage([]); + componentRef.setInput('currentUser', fakeUser()); + componentRef.setInput('customLinks', customLinks); + componentRef.setInput('forceLogin', true); fixture.detectChanges(); const menuButton = await harnessLoader.getHarness(MatButtonHarness.with({ selector: '.mobile-menu__button' })); @@ -148,6 +173,20 @@ describe('NavBarComponent', () => { expect(linkTexts).toEqual(['Catalog', 'Guides', 'link-name-1', 'link-name-2', 'Applications', 'Log out']); }); + it('should show custom links if login is not forced', async () => { + expectHomePage([]); + componentRef.setInput('customLinks', customLinks); + fixture.detectChanges(); + + const menuButton = await harnessLoader.getHarness(MatButtonHarness.with({ selector: '.mobile-menu__button' })); + await menuButton.click(); + fixture.detectChanges(); + + const links: NodeList = fixture.debugElement.nativeElement.querySelectorAll('.mobile-menu__link'); + const linkTexts = Array.from(links).map((el: Node) => el.textContent?.trim()); + expect(linkTexts).toEqual(['Catalog', 'Guides', 'link-name-1', 'link-name-2', 'Sign in']); + }); + it('should close menu when clicking outside', async () => { componentRef.setInput('currentUser', fakeUser()); expectHomePage(); diff --git a/gravitee-apim-portal-webui-next/src/components/nav-bar/nav-bar.component.ts b/gravitee-apim-portal-webui-next/src/components/nav-bar/nav-bar.component.ts index 4436902ada6..dc3d8540cdd 100644 --- a/gravitee-apim-portal-webui-next/src/components/nav-bar/nav-bar.component.ts +++ b/gravitee-apim-portal-webui-next/src/components/nav-bar/nav-bar.component.ts @@ -13,8 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Component, inject, Input, input, InputSignal } from '@angular/core'; +import { Component, computed, inject, Input, input, InputSignal } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; +import { isEmpty } from 'lodash'; import { User } from '../../entities/user/user'; import { ObservabilityBreakpointService } from '../../services/observability-breakpoint.service'; @@ -35,6 +36,11 @@ export class NavBarComponent { customLinks: InputSignal = input([]); currentUser: InputSignal = input({}); + forceLogin: InputSignal = input(false); logo: InputSignal = input(''); protected readonly isMobile = inject(ObservabilityBreakpointService).isMobile; + + protected isLoggedIn = computed(() => { + return !isEmpty(this.currentUser()); + }); } diff --git a/gravitee-apim-portal-webui-next/src/entities/common/data-response.ts b/gravitee-apim-portal-webui-next/src/entities/common/data-response.ts new file mode 100644 index 00000000000..ef519687fbe --- /dev/null +++ b/gravitee-apim-portal-webui-next/src/entities/common/data-response.ts @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2024 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Links } from './links'; + +export interface DataResponse { + data?: Array; + metadata?: { [key: string]: { [key: string]: object } }; + links?: Links; +} diff --git a/gravitee-apim-portal-webui-next/src/entities/common/links.ts b/gravitee-apim-portal-webui-next/src/entities/common/links.ts new file mode 100644 index 00000000000..8740810651b --- /dev/null +++ b/gravitee-apim-portal-webui-next/src/entities/common/links.ts @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2024 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export interface Links { + self?: string; + first?: string; + last?: string; + prev?: string; + next?: string; +} diff --git a/gravitee-apim-portal-webui-next/src/entities/configuration/identity-provider-type.ts b/gravitee-apim-portal-webui-next/src/entities/configuration/identity-provider-type.ts new file mode 100644 index 00000000000..9fac80810d4 --- /dev/null +++ b/gravitee-apim-portal-webui-next/src/entities/configuration/identity-provider-type.ts @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2024 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export type IdentityProviderType = 'GOOGLE' | 'GITHUB' | 'GRAVITEEIO_AM' | 'OIDC'; + +export const IdentityProviderType = { + GOOGLE: 'GOOGLE' as IdentityProviderType, + GITHUB: 'GITHUB' as IdentityProviderType, + GRAVITEEIO_AM: 'GRAVITEEIO_AM' as IdentityProviderType, + OIDC: 'OIDC' as IdentityProviderType, +}; diff --git a/gravitee-apim-portal-webui-next/src/entities/configuration/identity-provider.ts b/gravitee-apim-portal-webui-next/src/entities/configuration/identity-provider.ts new file mode 100644 index 00000000000..349a6a04e45 --- /dev/null +++ b/gravitee-apim-portal-webui-next/src/entities/configuration/identity-provider.ts @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2024 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { IdentityProviderType } from './identity-provider-type'; + +export interface IdentityProvider { + // Common props of all identity providers + id?: string; + name?: string; + description?: string; + client_id?: string; + email_required?: boolean; + type?: IdentityProviderType; + authorizationEndpoint?: string; + scopes?: Array; + + // Gravitee.io AM and OpenId Connect only + tokenIntrospectionEndpoint?: string; + userLogoutEndpoint?: string; + color?: string; + + // Google only + display?: string; + requiredUrlParams?: Array; + + // GitHub and Google only + optionalUrlParams?: Array; +} diff --git a/gravitee-apim-portal-webui-next/src/guards/auth.guard.spec.ts b/gravitee-apim-portal-webui-next/src/guards/auth.guard.spec.ts index ebb764cb306..57c3e62ee52 100644 --- a/gravitee-apim-portal-webui-next/src/guards/auth.guard.spec.ts +++ b/gravitee-apim-portal-webui-next/src/guards/auth.guard.spec.ts @@ -15,15 +15,19 @@ */ import { TestBed } from '@angular/core/testing'; import { ActivatedRoute, CanActivateFn, Router } from '@angular/router'; +import { OAuthService } from 'angular-oauth2-oidc'; import { authGuard } from './auth.guard'; import { fakeUser } from '../entities/user/user.fixtures'; +import { ConfigService } from '../services/config.service'; import { CurrentUserService } from '../services/current-user.service'; import { AppTestingModule } from '../testing/app-testing.module'; describe('authGuard', () => { let currentUserService: CurrentUserService; + let oauthService: OAuthService; let activatedRoute: ActivatedRoute; + let configService: ConfigService; let router: Router; const executeGuard: CanActivateFn = (...guardParameters) => TestBed.runInInjectionContext(() => authGuard(...guardParameters)); @@ -32,24 +36,66 @@ describe('authGuard', () => { imports: [AppTestingModule], }); currentUserService = TestBed.inject(CurrentUserService); + oauthService = TestBed.inject(OAuthService); activatedRoute = TestBed.inject(ActivatedRoute); + configService = TestBed.inject(ConfigService); router = TestBed.inject(Router); }); - it('should not allow anonymous user', () => { - currentUserService.user.set({}); - jest.spyOn(router, 'navigate'); + it('should allow authenticated user', () => { + const parseUrl = jest.spyOn(router, 'parseUrl'); + const createUrlTree = jest.spyOn(router, 'createUrlTree'); + currentUserService.user.set(fakeUser()); expect(executeGuard(activatedRoute.snapshot, { url: '', root: activatedRoute.snapshot })).toBeTruthy(); - expect(router.navigate).toBeCalledTimes(1); - expect(router.navigate).toHaveBeenCalledWith(['']); + expect(parseUrl).not.toHaveBeenCalled(); + expect(createUrlTree).not.toHaveBeenCalled(); }); - it('should allow authenticated user', () => { + it('should redirect authenticated user coming from SSO', () => { + const parseUrl = jest.spyOn(router, 'parseUrl'); + const createUrlTree = jest.spyOn(router, 'createUrlTree'); currentUserService.user.set(fakeUser()); - jest.spyOn(router, 'navigate'); + oauthService.state = encodeURIComponent('/redirectPath'); expect(executeGuard(activatedRoute.snapshot, { url: '', root: activatedRoute.snapshot })).toBeTruthy(); - expect(router.navigate).toBeCalledTimes(0); + expect(oauthService.state).toEqual(''); + expect(parseUrl).toHaveBeenCalledWith('/redirectPath'); + expect(createUrlTree).not.toHaveBeenCalled(); + }); + + it('should redirect unauthenticated user to login', () => { + const parseUrl = jest.spyOn(router, 'parseUrl'); + const createUrlTree = jest.spyOn(router, 'createUrlTree'); + configService.configuration.authentication!.forceLogin!.enabled = true; + const url = '/test'; + + expect(executeGuard(activatedRoute.snapshot, { url, root: activatedRoute.snapshot })).toBeTruthy(); + expect(parseUrl).not.toHaveBeenCalled(); + expect(createUrlTree).toHaveBeenCalledWith(['/log-in'], { queryParams: { redirectUrl: url } }); + }); + + it('should allow unauthenticated user, anonymous routes', () => { + const parseUrl = jest.spyOn(router, 'parseUrl'); + const createUrlTree = jest.spyOn(router, 'createUrlTree'); + configService.configuration.authentication!.forceLogin!.enabled = true; + + expect(executeGuard(activatedRoute.snapshot, { url: '/log-in', root: activatedRoute.snapshot })).toBeTruthy(); + expect(executeGuard(activatedRoute.snapshot, { url: '/sign-up', root: activatedRoute.snapshot })).toBeTruthy(); + expect(executeGuard(activatedRoute.snapshot, { url: '/log-in/reset-password', root: activatedRoute.snapshot })).toBeTruthy(); + + expect(parseUrl).not.toHaveBeenCalled(); + expect(createUrlTree).not.toHaveBeenCalled(); + }); + + it('should allow unauthenticated user, login not forced', () => { + const parseUrl = jest.spyOn(router, 'parseUrl'); + const createUrlTree = jest.spyOn(router, 'createUrlTree'); + configService.configuration.authentication!.forceLogin!.enabled = false; + + expect(executeGuard(activatedRoute.snapshot, { url: '/test', root: activatedRoute.snapshot })).toBeTruthy(); + + expect(parseUrl).not.toHaveBeenCalled(); + expect(createUrlTree).not.toHaveBeenCalled(); }); }); diff --git a/gravitee-apim-portal-webui-next/src/guards/auth.guard.ts b/gravitee-apim-portal-webui-next/src/guards/auth.guard.ts index c1537bc4c82..0cc144df144 100644 --- a/gravitee-apim-portal-webui-next/src/guards/auth.guard.ts +++ b/gravitee-apim-portal-webui-next/src/guards/auth.guard.ts @@ -14,10 +14,43 @@ * limitations under the License. */ import { inject } from '@angular/core'; -import { CanActivateFn, Router } from '@angular/router'; +import { CanActivateFn, Router, RouterStateSnapshot } from '@angular/router'; +import { OAuthService } from 'angular-oauth2-oidc'; +import { isEmpty } from 'lodash'; +import { ConfigService } from '../services/config.service'; import { CurrentUserService } from '../services/current-user.service'; -export const authGuard: CanActivateFn = (_route, _state) => { - return inject(CurrentUserService).isAuthenticated() || inject(Router).navigate(['']); +export const authGuard: CanActivateFn = (route, state) => { + return checkUserAuthenticated() || forceLogin(state); }; + +function checkUserAuthenticated() { + if (inject(CurrentUserService).isAuthenticated()) { + const redirectPath = getOAuthRedirectPath(); + return isEmpty(redirectPath) || inject(Router).parseUrl(redirectPath); + } + return false; +} + +function getOAuthRedirectPath() { + const oAuthService = inject(OAuthService); + const redirectPath = decodeURIComponent(oAuthService.state ?? ''); + oAuthService.state = ''; + return redirectPath; +} + +function forceLogin(state: RouterStateSnapshot) { + if (isForceLoginEnabled() && !isRouteAnonymous(state.url)) { + return inject(Router).navigate(['/log-in'], { queryParams: { redirectUrl: state.url } }); + } + return true; +} + +function isForceLoginEnabled(): boolean { + return inject(ConfigService).configuration.authentication?.forceLogin?.enabled ?? false; +} + +function isRouteAnonymous(url: string): boolean { + return url.startsWith('/log-in') || url.startsWith('/sign-up') || url.startsWith('/log-in/reset-password'); +} diff --git a/gravitee-apim-portal-webui-next/src/services/auth.service.spec.ts b/gravitee-apim-portal-webui-next/src/services/auth.service.spec.ts index ea668afb9a4..7c92eb0577e 100644 --- a/gravitee-apim-portal-webui-next/src/services/auth.service.spec.ts +++ b/gravitee-apim-portal-webui-next/src/services/auth.service.spec.ts @@ -15,9 +15,13 @@ */ import { HttpTestingController } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; +import { OAuthService } from 'angular-oauth2-oidc'; +import { of } from 'rxjs'; import { AuthService } from './auth.service'; -import { AppTestingModule, TESTING_BASE_URL } from '../testing/app-testing.module'; +import { IdentityProviderService } from './identity-provider.service'; +import { IdentityProvider } from '../entities/configuration/identity-provider'; +import { AppTestingModule, IdentityProviderServiceStub, OAuthServiceStub, TESTING_BASE_URL } from '../testing/app-testing.module'; describe('AuthService', () => { let service: AuthService; @@ -43,4 +47,36 @@ describe('AuthService', () => { expect(req.request.headers.get('Authorization')).toEqual('Basic dXNlcm5hbWU6cGFzc3dvcmQ='); req.flush(token); }); + + it('should redirect for SSO', () => { + const idp = { id: 'google', name: 'Google' }; + const redirectUrl = 'redirectUrl'; + + const storeProviderId = jest.spyOn(service, 'storeProviderId').mockReturnValue(); + const initCodeFlow = jest.spyOn(TestBed.inject(OAuthService) as unknown as OAuthServiceStub, 'initCodeFlow').mockReturnValue(); + + service.authenticateSSO(idp, redirectUrl); + + expect(initCodeFlow).toHaveBeenCalledWith(redirectUrl); + expect(storeProviderId).toHaveBeenCalled(); + }); + + it('should retrieve token on load from SSO provider', done => { + const idp = { id: 'google', name: 'Google' } as IdentityProvider; + + const getProviderId = jest.spyOn(service, 'getProviderId').mockReturnValue(idp.id!); + const getPortalIdentityProvider = jest + .spyOn(TestBed.inject(IdentityProviderService) as unknown as IdentityProviderServiceStub, 'getPortalIdentityProvider') + .mockReturnValue(of(idp)); + const tryLoginCodeFlow = jest + .spyOn(TestBed.inject(OAuthService) as unknown as OAuthServiceStub, 'tryLoginCodeFlow') + .mockResolvedValue(); + + service.load().subscribe(() => { + expect(getProviderId).toHaveBeenCalled(); + expect(getPortalIdentityProvider).toHaveBeenCalledWith(idp.id); + expect(tryLoginCodeFlow).toHaveBeenCalled(); + done(); + }); + }); }); diff --git a/gravitee-apim-portal-webui-next/src/services/auth.service.ts b/gravitee-apim-portal-webui-next/src/services/auth.service.ts index b400dfc49a4..08340c69936 100644 --- a/gravitee-apim-portal-webui-next/src/services/auth.service.ts +++ b/gravitee-apim-portal-webui-next/src/services/auth.service.ts @@ -15,9 +15,14 @@ */ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; +import { OAuthService } from 'angular-oauth2-oidc'; +import { catchError, map, Observable, switchMap } from 'rxjs'; +import { fromPromise } from 'rxjs/internal/observable/innerFrom'; +import { of } from 'rxjs/internal/observable/of'; import { ConfigService } from './config.service'; +import { IdentityProviderService } from './identity-provider.service'; +import { IdentityProvider } from '../entities/configuration/identity-provider'; interface Token { token_type: string; @@ -29,13 +34,15 @@ interface Token { }) export class AuthService { constructor( - private http: HttpClient, - private configuration: ConfigService, + private readonly http: HttpClient, + private readonly configService: ConfigService, + private readonly oauthService: OAuthService, + private readonly identityProviderService: IdentityProviderService, ) {} login(username: string, password: string) { return this.http.post( - `${this.configuration.baseURL}/auth/login`, + `${this.configService.baseURL}/auth/login`, {}, { headers: { @@ -46,6 +53,79 @@ export class AuthService { } logout(): Observable { - return this.http.post(`${this.configuration.baseURL}/auth/logout`, {}); + return this.http.post(`${this.configService.baseURL}/auth/logout`, {}); + } + + authenticateSSO(provider: IdentityProvider, redirectUrl: string) { + if (provider) { + this.storeProviderId(provider.id!); + this._configure(provider); + this.oauthService.initCodeFlow(redirectUrl); + } + } + + storeProviderId(providerId: string) { + localStorage.setItem('user-provider-id', providerId); + } + + getProviderId(): string { + return localStorage.getItem('user-provider-id')!; + } + + load() { + if (this.getProviderId()) { + return this._fetchProviderAndConfigure().pipe( + switchMap(() => { + return fromPromise( + this.oauthService.tryLoginCodeFlow({ + // 📝 The clear of the hash doesn't work correctly and keeps a piece of string which distorts angular routing and + // displays a 404. Disabling it solves the problem and the clear will be done with an angular internal redirection. + preventClearHashAfterLogin: true, + }), + ); + }), + ); + } else { + return of(undefined); + } + } + + private _fetchProviderAndConfigure() { + return this.identityProviderService.getPortalIdentityProvider(this.getProviderId()).pipe( + map(identityProvider => { + if (identityProvider) { + this._configure(identityProvider); + } + }), + catchError(() => of(undefined)), + ); + } + + private _configure(provider: IdentityProvider) { + const redirectUri = + window.location.origin + (window.location.pathname === '/' ? '' : window.location.pathname.replace('/user/logout', '/')); + this.oauthService.configure({ + clientId: provider.client_id, + loginUrl: provider.authorizationEndpoint, + tokenEndpoint: this.configService.baseURL + '/auth/oauth2/' + provider.id, + requireHttps: false, + issuer: provider.tokenIntrospectionEndpoint, + logoutUrl: provider.userLogoutEndpoint, + postLogoutRedirectUri: redirectUri, + scope: provider.scopes?.join(' '), + responseType: 'code', + redirectUri, + /* + added because with our current OIDC configuration, we don't know the real issuer. + For example, with keycloak, the issuer is "https://[host]:[port]/auth/realms/[realm_id]. + But in our configuration, we only have these endpoints: + - https://[host]:[port]/auth/realms/[realm_id]/protocol/openid-connect/token + - https://[host]:[port]/auth/realms/[realm_id]/protocol/openid-connect/token/introspect + - https://[host]:[port]/auth/realms/[realm_id]/protocol/openid-connect/auth + - https://[host]:[port]/auth/realms/[realm_id]/protocol/openid-connect/userinfo + - https://[host]:[port]/auth/realms/[realm_id]/protocol/openid-connect/logout + */ + skipIssuerCheck: true, + }); } } diff --git a/gravitee-apim-portal-webui-next/src/services/identity-provider.service.spec.ts b/gravitee-apim-portal-webui-next/src/services/identity-provider.service.spec.ts new file mode 100644 index 00000000000..b6ba9be94a2 --- /dev/null +++ b/gravitee-apim-portal-webui-next/src/services/identity-provider.service.spec.ts @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2024 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { IdentityProviderService } from './identity-provider.service'; +import { DataResponse } from '../entities/common/data-response'; +import { IdentityProvider } from '../entities/configuration/identity-provider'; +import { AppTestingModule, TESTING_BASE_URL } from '../testing/app-testing.module'; + +describe('IdentityProviderService', () => { + let service: IdentityProviderService; + let httpTestingController: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [AppTestingModule], + providers: [IdentityProviderService], + }); + service = TestBed.inject(IdentityProviderService); + httpTestingController = TestBed.inject(HttpTestingController); + }); + + it('should load identity provider by id', done => { + const provider = { id: 'google' }; + service.getPortalIdentityProvider(provider.id).subscribe(() => done()); + httpTestingController + .expectOne(`${TESTING_BASE_URL}/configuration/identities/${encodeURIComponent(String(provider.id))}`) + .flush(provider); + }); + + it('should load identity provider by id', done => { + const response: DataResponse = { + data: [{ id: 'google' }], + }; + service.getPortalIdentityProviders().subscribe(() => done()); + httpTestingController.expectOne(`${TESTING_BASE_URL}/configuration/identities`).flush(response); + }); +}); diff --git a/gravitee-apim-portal-webui-next/src/services/identity-provider.service.ts b/gravitee-apim-portal-webui-next/src/services/identity-provider.service.ts new file mode 100644 index 00000000000..03f9f279824 --- /dev/null +++ b/gravitee-apim-portal-webui-next/src/services/identity-provider.service.ts @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2024 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { ConfigService } from './config.service'; +import { DataResponse } from '../entities/common/data-response'; +import { IdentityProvider } from '../entities/configuration/identity-provider'; + +@Injectable({ + providedIn: 'root', +}) +export class IdentityProviderService { + constructor( + private readonly http: HttpClient, + private readonly configService: ConfigService, + ) {} + + public getPortalIdentityProviders(): Observable> { + return this.http.get>(`${this.configService.baseURL}/configuration/identities`); + } + + public getPortalIdentityProvider(identityProviderId: string): Observable { + return this.http.get( + `${this.configService.baseURL}/configuration/identities/${encodeURIComponent(String(identityProviderId))}`, + ); + } +} diff --git a/gravitee-apim-portal-webui-next/src/testing/app-testing.module.ts b/gravitee-apim-portal-webui-next/src/testing/app-testing.module.ts index 1c589f985f6..0fe8b95ac2a 100644 --- a/gravitee-apim-portal-webui-next/src/testing/app-testing.module.ts +++ b/gravitee-apim-portal-webui-next/src/testing/app-testing.module.ts @@ -18,10 +18,15 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; import { Injectable, NgModule } from '@angular/core'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { ActivatedRoute, Router } from '@angular/router'; +import { OAuthService } from 'angular-oauth2-oidc'; +import { Observable } from 'rxjs/internal/Observable'; import { of } from 'rxjs/internal/observable/of'; +import { DataResponse } from '../entities/common/data-response'; import { Configuration } from '../entities/configuration/configuration'; +import { IdentityProvider } from '../entities/configuration/identity-provider'; import { ConfigService } from '../services/config.service'; +import { IdentityProviderService } from '../services/identity-provider.service'; export const TESTING_BASE_URL = 'http://localhost:8083/portal/environments/DEFAULT'; export const TESTING_ACTIVATED_ROUTE = { @@ -30,22 +35,62 @@ export const TESTING_ACTIVATED_ROUTE = { params: of({ apiId: 'apiId' }), queryParams: of({}), }; + @Injectable() export class ConfigServiceStub { + private _configuration: Configuration = { + portalNext: { + banner: { + enabled: true, + title: 'Welcome to Gravitee Developer Portal!', + subtitle: 'Great subtitle', + }, + }, + authentication: { + localLogin: { + enabled: true, + }, + forceLogin: { + enabled: false, + }, + }, + }; + get baseURL(): string { return TESTING_BASE_URL; } get configuration(): Configuration { - return { - portalNext: { - banner: { - enabled: true, - title: 'Welcome to Gravitee Developer Portal!', - subtitle: 'Great subtitle', - }, - }, - }; + return this._configuration ?? {}; + } + + set configuration(configuration: Configuration) { + this._configuration = configuration; + } +} + +@Injectable() +export class IdentityProviderServiceStub { + providers: IdentityProvider[] = []; + + getPortalIdentityProviders(): Observable> { + return of({ data: this.providers } as unknown as DataResponse); + } + getPortalIdentityProvider(): Observable { + return of(); + } +} + +@Injectable() +export class OAuthServiceStub { + initCodeFlow() { + // nothing to do + } + tryLoginCodeFlow(): Promise { + return Promise.resolve(); + } + configure() { + // nothing to do } } @@ -65,6 +110,14 @@ export class TestRouterModule { provide: ConfigService, useClass: ConfigServiceStub, }, + { + provide: OAuthService, + useClass: OAuthServiceStub, + }, + { + provide: IdentityProviderService, + useClass: IdentityProviderServiceStub, + }, { provide: ActivatedRoute, useValue: TESTING_ACTIVATED_ROUTE, diff --git a/gravitee-apim-portal-webui-next/yarn.lock b/gravitee-apim-portal-webui-next/yarn.lock index 60a0bc05c3c..7ae490b6ad5 100644 --- a/gravitee-apim-portal-webui-next/yarn.lock +++ b/gravitee-apim-portal-webui-next/yarn.lock @@ -7477,6 +7477,18 @@ __metadata: languageName: node linkType: hard +"angular-oauth2-oidc@npm:20.0.2": + version: 20.0.2 + resolution: "angular-oauth2-oidc@npm:20.0.2" + dependencies: + tslib: "npm:^2.5.2" + peerDependencies: + "@angular/common": ">=20.0.0" + "@angular/core": ">=20.0.0" + checksum: 10c0/db22f23d4906dd6b413b2a01d832a35aa8f7e8db236eafc7d0c63fdf7e29e08ca3aea13241b16e7ca63d27f586d365dc6b1cce690a6fb6d6aaf3f88aeb89a21b + languageName: node + linkType: hard + "ansi-colors@npm:4.1.3, ansi-colors@npm:^4.1.3": version: 4.1.3 resolution: "ansi-colors@npm:4.1.3" @@ -11472,6 +11484,7 @@ __metadata: "@typescript-eslint/eslint-plugin": "npm:7.18.0" "@typescript-eslint/parser": "npm:7.18.0" angular-gridster2: "npm:20.2.3" + angular-oauth2-oidc: "npm:20.0.2" asciidoctor: "npm:3.0.4" chart.js: "npm:4.3.0" dompurify: "npm:3.2.7" @@ -19019,7 +19032,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:2.8.1, tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.2.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.6.0, tslib@npm:^2.6.2, tslib@npm:^2.8.1": +"tslib@npm:2.8.1, tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.2.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.5.2, tslib@npm:^2.6.0, tslib@npm:^2.6.2, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 diff --git a/gravitee-apim-rest-api/gravitee-apim-rest-api-model/src/main/java/io/gravitee/rest/api/model/parameters/Key.java b/gravitee-apim-rest-api/gravitee-apim-rest-api-model/src/main/java/io/gravitee/rest/api/model/parameters/Key.java index 0a4c304fa12..f94efe21901 100644 --- a/gravitee-apim-rest-api/gravitee-apim-rest-api-model/src/main/java/io/gravitee/rest/api/model/parameters/Key.java +++ b/gravitee-apim-rest-api/gravitee-apim-rest-api-model/src/main/java/io/gravitee/rest/api/model/parameters/Key.java @@ -128,7 +128,7 @@ public enum Key { ), PORTAL_NEXT_THEME_COLOR_BACKGROUND_CARD( "portal.next.theme.color.background.card", - "#F4F7FD", + "#ffffff", new HashSet<>(singletonList(ENVIRONMENT)) ), PORTAL_NEXT_THEME_CUSTOM_CSS("portal.next.theme.customCss", new HashSet<>(singletonList(ENVIRONMENT))),