From 424d51053e619b96db748c5efee8f17a3e6b8630 Mon Sep 17 00:00:00 2001 From: Nir Gur Arie Date: Sun, 29 Sep 2024 08:39:49 +0300 Subject: [PATCH] apps portal sdks --- .../applications-portal.component.spec.ts | 99 +++++++++++++++++++ .../applications-portal.component.ts | 77 +++++++++++++++ .../my-applications-portal.component.html | 5 + .../my-applications-portal.component.spec.ts | 36 +++++++ .../my-applications-portal.component.ts | 15 +++ .../my-applications-portal.scss | 18 ++++ .../examples/app/MyApplicationsPortal.tsx | 68 +++++++++++++ .../src/components/ApplicationsPortal.tsx | 77 +++++++++++++++ .../components/MyApplicationsPortal.vue | 17 ++++ .../sdks/vue-sdk/src/ApplicationsPortal.vue | 33 +++++++ .../vue-sdk/tests/ApplicationsPortal.test.ts | 43 ++++++++ 11 files changed, 488 insertions(+) create mode 100644 packages/sdks/angular-sdk/projects/angular-sdk/src/lib/components/applications-portal/applications-portal.component.spec.ts create mode 100644 packages/sdks/angular-sdk/projects/angular-sdk/src/lib/components/applications-portal/applications-portal.component.ts create mode 100644 packages/sdks/angular-sdk/projects/demo-app/src/app/my-applications-portal/my-applications-portal.component.html create mode 100644 packages/sdks/angular-sdk/projects/demo-app/src/app/my-applications-portal/my-applications-portal.component.spec.ts create mode 100644 packages/sdks/angular-sdk/projects/demo-app/src/app/my-applications-portal/my-applications-portal.component.ts create mode 100644 packages/sdks/angular-sdk/projects/demo-app/src/app/my-applications-portal/my-applications-portal.scss create mode 100644 packages/sdks/react-sdk/examples/app/MyApplicationsPortal.tsx create mode 100644 packages/sdks/react-sdk/src/components/ApplicationsPortal.tsx create mode 100644 packages/sdks/vue-sdk/example/components/MyApplicationsPortal.vue create mode 100644 packages/sdks/vue-sdk/src/ApplicationsPortal.vue create mode 100644 packages/sdks/vue-sdk/tests/ApplicationsPortal.test.ts diff --git a/packages/sdks/angular-sdk/projects/angular-sdk/src/lib/components/applications-portal/applications-portal.component.spec.ts b/packages/sdks/angular-sdk/projects/angular-sdk/src/lib/components/applications-portal/applications-portal.component.spec.ts new file mode 100644 index 000000000..7ca334dff --- /dev/null +++ b/packages/sdks/angular-sdk/projects/angular-sdk/src/lib/components/applications-portal/applications-portal.component.spec.ts @@ -0,0 +1,99 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ApplicationsPortalComponent } from './applications-portal.component'; +import createSdk from '@descope/web-js-sdk'; +import { DescopeAuthConfig } from '../../types/types'; +import { CUSTOM_ELEMENTS_SCHEMA, EventEmitter } from '@angular/core'; +import mocked = jest.mocked; + +jest.mock('@descope/web-js-sdk'); +//Mock DescopeApplicationsPortalWidget +jest.mock('@descope/applications-portal-widget', () => { + return jest.fn(() => { + // Create a mock DOM element + return document.createElement('descope-applications-portal-widget'); + }); +}); + +describe('DescopeApplicationsPortalComponent', () => { + let component: ApplicationsPortalComponent; + let fixture: ComponentFixture; + let mockedCreateSdk: jest.Mock; + const onSessionTokenChangeSpy = jest.fn(); + const onAuditChangeSpy = jest.fn(); + const afterRequestHooksSpy = jest.fn(); + const mockConfig: DescopeAuthConfig = { + projectId: 'someProject' + }; + + beforeEach(() => { + mockedCreateSdk = mocked(createSdk); + + mockedCreateSdk.mockReturnValue({ + onSessionTokenChange: onSessionTokenChangeSpy, + onAuditChange: onAuditChangeSpy, + httpClient: { + hooks: { + afterRequest: afterRequestHooksSpy + } + } + }); + + TestBed.configureTestingModule({ + schemas: [CUSTOM_ELEMENTS_SCHEMA], + providers: [ + DescopeAuthConfig, + { provide: DescopeAuthConfig, useValue: mockConfig } + ] + }); + + fixture = TestBed.createComponent(ApplicationsPortalComponent); + component = fixture.componentInstance; + component.projectId = '123'; + component.widgetId = 'widget-1'; + component.logout = new EventEmitter(); + component.logger = { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn() + }; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + const html: HTMLElement = fixture.nativeElement; + const webComponentHtml = html.querySelector( + 'descope-applications-portal-widget' + ); + expect(webComponentHtml).toBeDefined(); + }); + + it('should correctly setup attributes based on inputs', () => { + const html: HTMLElement = fixture.nativeElement; + const webComponentHtml = html.querySelector( + 'descope-applications-portal-widget' + )!; + expect(webComponentHtml.getAttribute('project-id')).toStrictEqual('123'); + expect(webComponentHtml.getAttribute('widget-id')).toStrictEqual( + 'widget-1' + ); + expect(webComponentHtml.getAttribute('logger')).toBeDefined(); + }); + + it('should emit logout when web component emits logout', () => { + const html: HTMLElement = fixture.nativeElement; + const webComponentHtml = html.querySelector( + 'descope-applications-portal-widget' + )!; + + const event = { + detail: 'logout' + }; + component.logout.subscribe((e) => { + expect(afterRequestHooksSpy).toHaveBeenCalled(); + expect(e.detail).toHaveBeenCalledWith(event.detail); + }); + webComponentHtml.dispatchEvent(new CustomEvent('logout', event)); + }); +}); diff --git a/packages/sdks/angular-sdk/projects/angular-sdk/src/lib/components/applications-portal/applications-portal.component.ts b/packages/sdks/angular-sdk/projects/angular-sdk/src/lib/components/applications-portal/applications-portal.component.ts new file mode 100644 index 000000000..15c34d077 --- /dev/null +++ b/packages/sdks/angular-sdk/projects/angular-sdk/src/lib/components/applications-portal/applications-portal.component.ts @@ -0,0 +1,77 @@ +import { + Component, + ElementRef, + EventEmitter, + Input, + OnChanges, + OnInit, + Output +} from '@angular/core'; +import DescopeApplicationsPortalWidget from '@descope/applications-portal-widget'; +import { ILogger } from '@descope/web-component'; +import { DescopeAuthConfig } from '../../types/types'; + +@Component({ + selector: 'applications-portal', + standalone: true, + template: '' +}) +export class ApplicationsPortalComponent implements OnInit, OnChanges { + projectId: string; + baseUrl?: string; + baseStaticUrl?: string; + @Input() widgetId: string; + + @Input() theme: 'light' | 'dark' | 'os'; + @Input() debug: boolean; + @Input() logger: ILogger; + + @Output() logout: EventEmitter = new EventEmitter(); + + private readonly webComponent = new DescopeApplicationsPortalWidget(); + + constructor( + private elementRef: ElementRef, + descopeConfig: DescopeAuthConfig + ) { + this.projectId = descopeConfig.projectId; + this.baseUrl = descopeConfig.baseUrl; + this.baseStaticUrl = descopeConfig.baseStaticUrl; + } + + ngOnInit() { + this.setupWebComponent(); + this.elementRef.nativeElement.appendChild(this.webComponent); + } + + ngOnChanges(): void { + this.setupWebComponent(); + } + + private setupWebComponent() { + this.webComponent.setAttribute('project-id', this.projectId); + this.webComponent.setAttribute('widget-id', this.widgetId); + if (this.baseUrl) { + this.webComponent.setAttribute('base-url', this.baseUrl); + } + if (this.baseStaticUrl) { + this.webComponent.setAttribute('base-static-url', this.baseStaticUrl); + } + if (this.theme) { + this.webComponent.setAttribute('theme', this.theme); + } + if (this.debug) { + this.webComponent.setAttribute('debug', this.debug.toString()); + } + + if (this.logger) { + (this.webComponent as any).logger = this.logger; + } + + if (this.logout) { + this.webComponent.addEventListener('logout', (e: Event) => { + this.logout?.emit(e as CustomEvent); + }); + } + } +} diff --git a/packages/sdks/angular-sdk/projects/demo-app/src/app/my-applications-portal/my-applications-portal.component.html b/packages/sdks/angular-sdk/projects/demo-app/src/app/my-applications-portal/my-applications-portal.component.html new file mode 100644 index 000000000..d5f2d8f80 --- /dev/null +++ b/packages/sdks/angular-sdk/projects/demo-app/src/app/my-applications-portal/my-applications-portal.component.html @@ -0,0 +1,5 @@ + diff --git a/packages/sdks/angular-sdk/projects/demo-app/src/app/my-applications-portal/my-applications-portal.component.spec.ts b/packages/sdks/angular-sdk/projects/demo-app/src/app/my-applications-portal/my-applications-portal.component.spec.ts new file mode 100644 index 000000000..e0552d0b3 --- /dev/null +++ b/packages/sdks/angular-sdk/projects/demo-app/src/app/my-applications-portal/my-applications-portal.component.spec.ts @@ -0,0 +1,36 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MyApplicationsPortalComponent } from './my-applications-portal.component'; +import createSdk from '@descope/web-js-sdk'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { DescopeAuthConfig } from '../../../../angular-sdk/src/lib/types/types'; +import mocked = jest.mocked; + +jest.mock('@descope/web-js-sdk'); + +describe('MyApplicationsPortalComponent', () => { + let component: MyApplicationsPortalComponent; + let fixture: ComponentFixture; + + let mockedCreateSdk: jest.Mock; + + beforeEach(() => { + mockedCreateSdk = mocked(createSdk); + mockedCreateSdk.mockReturnValue({}); + + TestBed.configureTestingModule({ + schemas: [NO_ERRORS_SCHEMA], + declarations: [MyApplicationsPortalComponent], + providers: [ + DescopeAuthConfig, + { provide: DescopeAuthConfig, useValue: { projectId: 'test' } } + ] + }); + fixture = TestBed.createComponent(MyApplicationsPortalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/packages/sdks/angular-sdk/projects/demo-app/src/app/my-applications-portal/my-applications-portal.component.ts b/packages/sdks/angular-sdk/projects/demo-app/src/app/my-applications-portal/my-applications-portal.component.ts new file mode 100644 index 000000000..fc037a474 --- /dev/null +++ b/packages/sdks/angular-sdk/projects/demo-app/src/app/my-applications-portal/my-applications-portal.component.ts @@ -0,0 +1,15 @@ +import { Component } from '@angular/core'; +import { environment } from '../../environments/environment'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'app-my-applications-portal', + templateUrl: './my-applications-portal.component.html', + styleUrls: ['./my-applications-portal.scss'] +}) +export class MyApplicationsPortalComponent { + theme = (environment.descopeTheme as 'light' | 'dark' | 'os') ?? 'os'; + debugMode = environment.descopeDebugMode ?? false; + + constructor(private router: Router) {} +} diff --git a/packages/sdks/angular-sdk/projects/demo-app/src/app/my-applications-portal/my-applications-portal.scss b/packages/sdks/angular-sdk/projects/demo-app/src/app/my-applications-portal/my-applications-portal.scss new file mode 100644 index 000000000..9bb69415c --- /dev/null +++ b/packages/sdks/angular-sdk/projects/demo-app/src/app/my-applications-portal/my-applications-portal.scss @@ -0,0 +1,18 @@ +:host { + height: 100vh; + position: relative; +} +main { + border-radius: 10px; + margin: auto; + border: 1px solid lightgray; + padding: 20px; + max-width: 700px; + box-shadow: + 13px 13px 20px #cbced1, + -13px -13px 20px #fff; + background: #ecf0f3; + position: relative; + top: 50%; + transform: translateY(-50%); +} diff --git a/packages/sdks/react-sdk/examples/app/MyApplicationsPortal.tsx b/packages/sdks/react-sdk/examples/app/MyApplicationsPortal.tsx new file mode 100644 index 000000000..a551c1c97 --- /dev/null +++ b/packages/sdks/react-sdk/examples/app/MyApplicationsPortal.tsx @@ -0,0 +1,68 @@ +import type { UserResponse } from '@descope/web-js-sdk'; +import React, { useCallback } from 'react'; +import { Link } from 'react-router-dom'; +import { useDescope, useUser, ApplicationsPortal } from '../../src'; + +const getUserDisplayName = (user?: UserResponse) => + user?.name || user?.loginIds?.[0] || ''; + +const MyApplicationsPortal = () => { + // useUser retrieves the logged in user information + const { user } = useUser(); + // useDescope retrieves Descope SDK for further operations related to authentication + // such as logout + const sdk = useDescope(); + + const onLogout = useCallback(() => { + sdk.logout(); + }, [sdk]); + + return ( + <> +
+

+ Home +

+
+

+ User:{' '} + + {getUserDisplayName(user)} + +

+

+ +

+

+ {process.env.DESCOPE_STEP_UP_FLOW_ID && ( + + Step Up + + )} +

+
+
+

My Profile

+ + + ); +}; + +export default MyApplicationsPortal; diff --git a/packages/sdks/react-sdk/src/components/ApplicationsPortal.tsx b/packages/sdks/react-sdk/src/components/ApplicationsPortal.tsx new file mode 100644 index 000000000..4a2f58af1 --- /dev/null +++ b/packages/sdks/react-sdk/src/components/ApplicationsPortal.tsx @@ -0,0 +1,77 @@ +import React, { + lazy, + Suspense, + useEffect, + useImperativeHandle, + useState, +} from 'react'; +import Context from '../hooks/Context'; +import { ApplicationsPortalProps } from '../types'; + +// web-component code uses browser API, but can be used in SSR apps, hence the lazy loading +const ApplicationsPortalWC = lazy(async () => { + await import('@descope/applications-portal-widget'); + + return { + default: ({ + projectId, + baseUrl, + baseStaticUrl, + innerRef, + widgetId, + theme, + debug, + }) => ( + + ), + }; +}); + +const ApplicationsPortal = React.forwardRef< + HTMLElement, + ApplicationsPortalProps +>(({ logger, theme, debug, widgetId, onLogout }, ref) => { + const [innerRef, setInnerRef] = useState(null); + + useImperativeHandle(ref, () => innerRef); + + const { projectId, baseUrl, baseStaticUrl } = React.useContext(Context); + + useEffect(() => { + if (innerRef && logger) { + innerRef.logger = logger; + } + }, [innerRef, logger]); + + useEffect(() => { + if (innerRef && onLogout) { + innerRef.addEventListener('logout', onLogout); + return () => innerRef.removeEventListener('logout', onLogout); + } + return undefined; + }, [innerRef, onLogout]); + + return ( + + + + ); +}); + +export default ApplicationsPortal; diff --git a/packages/sdks/vue-sdk/example/components/MyApplicationsPortal.vue b/packages/sdks/vue-sdk/example/components/MyApplicationsPortal.vue new file mode 100644 index 000000000..5170be20b --- /dev/null +++ b/packages/sdks/vue-sdk/example/components/MyApplicationsPortal.vue @@ -0,0 +1,17 @@ + + + + + + diff --git a/packages/sdks/vue-sdk/src/ApplicationsPortal.vue b/packages/sdks/vue-sdk/src/ApplicationsPortal.vue new file mode 100644 index 000000000..1cab7fc4a --- /dev/null +++ b/packages/sdks/vue-sdk/src/ApplicationsPortal.vue @@ -0,0 +1,33 @@ + + + + diff --git a/packages/sdks/vue-sdk/tests/ApplicationsPortal.test.ts b/packages/sdks/vue-sdk/tests/ApplicationsPortal.test.ts new file mode 100644 index 000000000..e17947975 --- /dev/null +++ b/packages/sdks/vue-sdk/tests/ApplicationsPortal.test.ts @@ -0,0 +1,43 @@ +import { shallowMount, mount } from '@vue/test-utils'; +import UserProfile from '../src/UserProfile.vue'; +import { ApplicationsPortal } from '../../react-sdk/src'; + +jest.mock('../src/hooks', () => ({ + useOptions: () => ({ projectId: 'project1', baseUrl: 'baseUrl' }), + useDescope: () => ({ httpClient: { hooks: { afterRequest: jest.fn() } } }), + useUser: () => ({}), + useSession: () => ({}), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +globalThis.Response = class {}; + +describe('ApplicationsPortal.vue', () => { + it('renders the widget', () => { + const wrapper = shallowMount(ApplicationsPortal, { + props: { widgetId: 'widget1' }, + }); + expect(wrapper.find('descope-applications-portal-widget').exists()).toBe( + true, + ); + }); + + it('renders a widget with the correct props', () => { + const wrapper = mount(UserProfile, { + props: { + widgetId: 'widget1', + theme: 'test-theme', + locale: 'test-locale', + debug: true, + }, + }); + + const descopeWc = wrapper.find('descope-applications-portal-widget'); + expect(descopeWc.exists()).toBe(true); + expect(descopeWc.attributes('project-id')).toBe('project1'); + expect(descopeWc.attributes('base-url')).toBe('baseUrl'); + expect(descopeWc.attributes('theme')).toBe('test-theme'); + expect(descopeWc.attributes('widget-id')).toBe('widget1'); + expect(descopeWc.attributes('debug')).toBe('true'); + }); +});