UNPKG

sensei-uaepass

Version:

🥋 Master of UAE Pass integration! Angular OAuth 2.0 (PKCE) client with sensei-level signals-based state management, multi-language support, and elegant UI components.

811 lines (797 loc) 41.9 kB
import * as i0 from '@angular/core'; import { InjectionToken, inject, signal, computed, Injectable, input, output, ChangeDetectionStrategy, Component, effect } from '@angular/core'; import { HttpClient, HttpHeaders } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import { timeout } from 'rxjs/operators'; import * as i1 from '@angular/common'; import { CommonModule } from '@angular/common'; const UAE_PASS_CONFIG = new InjectionToken('UAE_PASS_CONFIG'); function provideUaePass(config) { return [{ provide: UAE_PASS_CONFIG, useValue: config }]; } // Centralized enums for sensei-uaepass var UaePassAuthStatus; (function (UaePassAuthStatus) { UaePassAuthStatus["Idle"] = "idle"; UaePassAuthStatus["Authorizing"] = "authorizing"; UaePassAuthStatus["ExchangingToken"] = "exchangingToken"; UaePassAuthStatus["Authenticated"] = "authenticated"; UaePassAuthStatus["Error"] = "error"; UaePassAuthStatus["LoggedOut"] = "loggedOut"; })(UaePassAuthStatus || (UaePassAuthStatus = {})); var UaePassStorageMode; (function (UaePassStorageMode) { UaePassStorageMode["None"] = "none"; UaePassStorageMode["Session"] = "session"; UaePassStorageMode["Local"] = "local"; })(UaePassStorageMode || (UaePassStorageMode = {})); var UaePassAcr; (function (UaePassAcr) { UaePassAcr["MobileOnDevice"] = "urn:digitalid:authentication:flow:mobileondevice"; UaePassAcr["Web"] = "urn:safelayer:tws:policies:authentication:level:low"; })(UaePassAcr || (UaePassAcr = {})); var UaePassLanguageCode; (function (UaePassLanguageCode) { UaePassLanguageCode["En"] = "en"; UaePassLanguageCode["Ar"] = "ar"; })(UaePassLanguageCode || (UaePassLanguageCode = {})); // Optional convenience if consumers prefer enums over booleans for environment var UaePassEnvironment; (function (UaePassEnvironment) { UaePassEnvironment["Production"] = "production"; UaePassEnvironment["Staging"] = "staging"; })(UaePassEnvironment || (UaePassEnvironment = {})); var OAuthResponseType; (function (OAuthResponseType) { OAuthResponseType["Code"] = "code"; })(OAuthResponseType || (OAuthResponseType = {})); var CodeChallengeMethod; (function (CodeChallengeMethod) { CodeChallengeMethod["S256"] = "S256"; })(CodeChallengeMethod || (CodeChallengeMethod = {})); var OAuthGrantType; (function (OAuthGrantType) { OAuthGrantType["AuthorizationCode"] = "authorization_code"; })(OAuthGrantType || (OAuthGrantType = {})); // UAE PASS constants and endpoint helpers // Mirrors Flutter implementation in lib/uaepass/constant.dart const UAE_PASS_BASE_URL = { prod: 'https://id.uaepass.ae', stg: 'https://stg-id.uaepass.ae', }; // Backward-compatible constant mapping to enums const UAE_PASS_ACR = { mobileOnDevice: UaePassAcr.MobileOnDevice, web: UaePassAcr.Web, }; function baseUrl(isProduction) { return isProduction ? UAE_PASS_BASE_URL.prod : UAE_PASS_BASE_URL.stg; } function authorizeUrl(isProduction) { return `${baseUrl(isProduction)}/idshub/authorize`; } function tokenUrl(isProduction) { return `${baseUrl(isProduction)}/idshub/token`; } function userInfoUrl(isProduction) { return `${baseUrl(isProduction)}/idshub/userinfo`; } function logoutUrl(isProduction, redirectUri) { return `${baseUrl(isProduction)}/idshub/logout?redirect_uri=${encodeURIComponent(redirectUri)}`; } // Ephemeral storage across redirect for PKCE + state // Uses sessionStorage to survive full-page redirects and be cleared easily. const PREFIX$1 = 'uae-pass:'; function key(name) { return `${PREFIX$1}${name}`; } const UaePassMemory = { setState(state) { if (typeof sessionStorage === 'undefined') return; sessionStorage.setItem(key('state'), state); }, getState() { if (typeof sessionStorage === 'undefined') return null; return sessionStorage.getItem(key('state')); }, setCodeVerifier(verifier) { if (typeof sessionStorage === 'undefined') return; sessionStorage.setItem(key('code_verifier'), verifier); }, getCodeVerifier() { if (typeof sessionStorage === 'undefined') return null; return sessionStorage.getItem(key('code_verifier')); }, clear() { if (typeof sessionStorage === 'undefined') return; sessionStorage.removeItem(key('state')); sessionStorage.removeItem(key('code_verifier')); }, }; // PKCE and state utilities for UAE PASS OAuth 2.0 // Mirrors behavior of Flutter implementation (random state, S256 code challenge) const PKCE_CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; function randomString(length, charset = PKCE_CHARSET) { const result = []; const cryptoObj = globalThis.crypto || globalThis.msCrypto; if (!cryptoObj) { // Non-cryptographic fallback; consumers should polyfill crypto in SSR if needed for (let i = 0; i < length; i++) { result.push(charset[Math.floor(Math.random() * charset.length)]); } return result.join(''); } const rnd = new Uint8Array(length); cryptoObj.getRandomValues(rnd); for (let i = 0; i < length; i++) { result.push(charset[rnd[i] % charset.length]); } return result.join(''); } function generateState(length = 32) { return randomString(length); } function base64Encode(bytes) { // Avoid relying on btoa (not available in Node/SSR). Encode manually with correct padding. const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; let output = ''; let i = 0; while (i < bytes.length) { const o1 = bytes[i++]; const o2 = i < bytes.length ? bytes[i++] : NaN; const o3 = i < bytes.length ? bytes[i++] : NaN; const c1 = o1 >> 2; const c2 = ((o1 & 0x03) << 4) | (isNaN(o2) ? 0 : (o2 >> 4)); const c3 = isNaN(o2) ? 64 : ((o2 & 0x0f) << 2) | (isNaN(o3) ? 0 : (o3 >> 6)); const c4 = isNaN(o3) ? 64 : (o3 & 0x3f); output += chars[c1] + chars[c2] + chars[c3] + chars[c4]; } return output; } function base64UrlEncode(bytes) { const base64 = base64Encode(bytes); return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); } async function sha256(input) { const encoder = new TextEncoder(); const data = encoder.encode(input); const cryptoObj = globalThis.crypto ?? globalThis.msCrypto; if (!cryptoObj?.subtle) { throw new Error('WebCrypto SubtleCrypto is not available'); } const digest = await cryptoObj.subtle.digest('SHA-256', data); return new Uint8Array(digest); } async function generateCodeChallengeS256(codeVerifier) { const hash = await sha256(codeVerifier); return base64UrlEncode(hash); } async function generatePkcePair() { const codeVerifier = randomString(64); const codeChallenge = await generateCodeChallengeS256(codeVerifier); return { codeVerifier, codeChallenge }; } const PREFIX = 'uae-pass:'; const TOKENS_KEY = `${PREFIX}tokens`; const PROFILE_KEY = `${PREFIX}profile`; function normalizeMode(mode) { // Enum values are already the exact string values if (mode === 'session' || mode === 'local' || mode === 'none') return mode; // Typescript enum runtime value is the string literal as well return mode; } function getStore(mode) { const m = normalizeMode(mode); if (typeof window === 'undefined') return null; try { if (m === 'session') return window.sessionStorage ?? null; if (m === 'local') return window.localStorage ?? null; } catch { // Storage may be blocked } return null; } function saveTokens(mode, tokens) { const store = getStore(mode); if (!store) return; if (!tokens) { try { store.removeItem(TOKENS_KEY); } catch { } return; } try { store.setItem(TOKENS_KEY, JSON.stringify(tokens)); } catch { } } function loadTokens(mode) { const store = getStore(mode); if (!store) return null; try { const raw = store.getItem(TOKENS_KEY); return raw ? JSON.parse(raw) : null; } catch { return null; } } function saveProfile(mode, profile) { const store = getStore(mode); if (!store) return; if (!profile) { try { store.removeItem(PROFILE_KEY); } catch { } return; } try { store.setItem(PROFILE_KEY, JSON.stringify(profile)); } catch { } } function loadProfile(mode) { const store = getStore(mode); if (!store) return null; try { const raw = store.getItem(PROFILE_KEY); return raw ? JSON.parse(raw) : null; } catch { return null; } } function clearAll(mode) { const store = getStore(mode); if (!store) return; try { store.removeItem(TOKENS_KEY); store.removeItem(PROFILE_KEY); } catch { } } const UAE_PASS_TEXTS_EN = { signInWithUaePass: 'Sign with UAE PASS', completingSignIn: 'Completing sign-in…', signedInSuccessfully: 'Signed in successfully!', redirectingToHomePage: 'Redirecting to home page...', error: 'Error', returnToHome: 'Return to home', loading: 'Loading', pleaseWait: 'Please wait', windowNotAvailable: 'Window is not available to redirect', noUrlContext: 'No URL context available to handle callback', securityCheckFailed: 'Security check failed: state mismatch', tokenExchangeFailed: 'Failed to obtain access token', noUserProfile: 'No user profile data received', userProfileFetchFailed: 'Failed to fetch user profile', uaePass: 'UAE PASS' }; const UAE_PASS_TEXTS_AR = { signInWithUaePass: 'تسجيل الدخول بهوية الإمارات الرقمية', completingSignIn: 'جاري إكمال تسجيل الدخول…', signedInSuccessfully: 'تم تسجيل الدخول بنجاح!', redirectingToHomePage: 'جاري التوجيه إلى الصفحة الرئيسية...', error: 'خطأ', returnToHome: 'العودة إلى الصفحة الرئيسية', loading: 'جاري التحميل', pleaseWait: 'يرجى الانتظار', windowNotAvailable: 'النافذة غير متاحة للتوجيه', noUrlContext: 'لا يوجد سياق URL متاح للتعامل مع الاستدعاء', securityCheckFailed: 'فشل في الفحص الأمني: عدم تطابق الحالة', tokenExchangeFailed: 'فشل في الحصول على رمز الوصول', noUserProfile: 'لم يتم استلام بيانات الملف الشخصي للمستخدم', userProfileFetchFailed: 'فشل في جلب الملف الشخصي للمستخدم', uaePass: 'هوية الإمارات الرقمية' }; function getUaePassTexts(language = 'en') { return language === 'ar' ? UAE_PASS_TEXTS_AR : UAE_PASS_TEXTS_EN; } class UaePassAuthService { http = inject(HttpClient); cfg = inject(UAE_PASS_CONFIG); // Signals-based state _status = signal(UaePassAuthStatus.Idle); _tokens = signal(null); _profile = signal(null); _error = signal(null); status = this._status.asReadonly(); tokens = this._tokens.asReadonly(); profile = this._profile.asReadonly(); error = this._error.asReadonly(); isAuthenticated = computed(() => this._status() === UaePassAuthStatus.Authenticated && !!this._tokens()); // Localized texts texts = computed(() => { const lang = this.cfg.language || 'en'; return getUaePassTexts(lang); }); // Defaults requestTimeoutMs() { return this.cfg.requestTimeoutMs ?? 20_000; } storageMode() { return this.cfg.storage ?? 'none'; } constructor() { // Restore from storage if configured const tokens = loadTokens(this.storageMode()); if (tokens) { this._tokens.set(tokens); const profile = loadProfile(this.storageMode()); if (profile) this._profile.set(profile); this._status.set(UaePassAuthStatus.Authenticated); } } // 1) Build UAE PASS authorize URL with PKCE + state; store ephemeral values in sessionStorage async buildAuthorizeUrl() { const state = generateState(); const { codeVerifier, codeChallenge } = await generatePkcePair(); UaePassMemory.setState(state); UaePassMemory.setCodeVerifier(codeVerifier); const params = new URLSearchParams({ response_type: OAuthResponseType.Code, client_id: this.cfg.clientId, scope: this.cfg.scope ?? 'urn:uae:digitalid:profile:general', state, redirect_uri: this.cfg.redirectUri, ui_locales: this.cfg.language ?? UaePassLanguageCode.En, acr_values: UaePassAcr.Web, // Web ACR for browser apps code_challenge: codeChallenge, code_challenge_method: CodeChallengeMethod.S256, }); return `${authorizeUrl(this.cfg.isProduction)}?${params.toString()}`; } // 2) Redirect the browser to UAE PASS async redirectToAuthorization() { this._status.set(UaePassAuthStatus.Authorizing); const url = await this.buildAuthorizeUrl(); if (typeof window !== 'undefined' && window?.location) { window.location.assign(url); } else { this._status.set(UaePassAuthStatus.Error); this._error.set(this.texts().windowNotAvailable); } } // 3) Handle callback on your redirect route. Provide url or it will use window.location.href async handleRedirectCallback(currentUrl) { const href = currentUrl ?? (typeof window !== 'undefined' ? window.location.href : ''); if (!href) { this._status.set(UaePassAuthStatus.Error); this._error.set(this.texts().noUrlContext); return; } const url = new URL(href); const returnedState = url.searchParams.get('state'); const code = url.searchParams.get('code'); const error = url.searchParams.get('error'); const errorDescription = url.searchParams.get('error_description'); if (!code) { // If provider returned an error, surface it if (error) { this._status.set(UaePassAuthStatus.Error); this._error.set(`${error}${errorDescription ? `: ${decodeURIComponent(errorDescription)}` : ''}`); UaePassMemory.clear(); return; } // No-op if this is not the redirect URL return; } const expectedState = UaePassMemory.getState(); if (!expectedState || returnedState !== expectedState) { this._status.set(UaePassAuthStatus.Error); this._error.set(this.texts().securityCheckFailed); // Clear ephemeral state to avoid reuse UaePassMemory.clear(); return; } this._status.set(UaePassAuthStatus.ExchangingToken); try { const tokens = await this.exchangeToken(code); this._tokens.set(tokens); saveTokens(this.storageMode(), tokens); this._status.set(UaePassAuthStatus.Authenticated); // Optionally fetch user info try { const profile = await this.fetchUserInfo(tokens.access_token); if (profile) { this._profile.set(profile); saveProfile(this.storageMode(), profile); } else { console.warn(`UAE Pass: ${this.texts().noUserProfile}`); } } catch (e) { console.error(`UAE Pass: ${this.texts().userProfileFetchFailed}:`, e); // Non-fatal - user is still authenticated even without profile } } catch (e) { this._status.set(UaePassAuthStatus.Error); this._error.set(e instanceof Error ? e.message : String(e)); } finally { UaePassMemory.clear(); } } // 4) Exchange code for tokens async exchangeToken(code) { const verifier = UaePassMemory.getCodeVerifier() ?? ''; // If a proxy is configured, prefer it (avoids exposing client_secret in the browser) if (this.cfg.tokenProxyUrl) { const body = { code, redirect_uri: this.cfg.redirectUri, code_verifier: verifier, }; const tokens = await firstValueFrom(this.http .post(this.cfg.tokenProxyUrl, body) .pipe(timeout(this.requestTimeoutMs()))); if (!tokens?.access_token) throw new Error(this.texts().tokenExchangeFailed); return tokens; } // Direct call to UAE PASS token endpoint (only safe if your tenant allows public SPA flow without secret) const tokenEndpoint = tokenUrl(this.cfg.isProduction); const params = new URLSearchParams(); params.set('redirect_uri', this.cfg.redirectUri); params.set('client_id', this.cfg.clientId); params.set('grant_type', OAuthGrantType.AuthorizationCode); params.set('code', code); params.set('code_verifier', verifier); if (this.cfg.clientSecret) { params.set('client_secret', this.cfg.clientSecret); } const headers = new HttpHeaders({ 'Content-Type': 'application/x-www-form-urlencoded', }); const tokens = await firstValueFrom(this.http .post(tokenEndpoint, params.toString(), { headers }) .pipe(timeout(this.requestTimeoutMs()))); if (!tokens?.access_token) throw new Error(this.texts().tokenExchangeFailed); return tokens; } // 5) Fetch user profile (optional) async fetchUserInfo(accessToken) { if (this.cfg.userInfoProxyUrl) { const profile = await firstValueFrom(this.http .post(this.cfg.userInfoProxyUrl, { token: accessToken, }) .pipe(timeout(this.requestTimeoutMs()))); return profile ?? null; } const headers = new HttpHeaders({ 'Content-Type': 'application/x-www-form-urlencoded', Authorization: `Bearer ${accessToken}`, }); const url = userInfoUrl(this.cfg.isProduction); const profile = await firstValueFrom(this.http .get(url, { headers }) .pipe(timeout(this.requestTimeoutMs()))); return profile ?? null; } // 6) Logout: clear local state and logout from UAE Pass servers (official flow) logout() { this._status.set(UaePassAuthStatus.LoggedOut); this._tokens.set(null); this._profile.set(null); this._error.set(null); clearAll(this.storageMode()); UaePassMemory.clear(); if (typeof window !== 'undefined' && window?.location) { // Use logoutRedirectUri if provided, otherwise use redirectUri const logoutUri = this.cfg.logoutRedirectUri || this.cfg.redirectUri; const url = logoutUrl(this.cfg.isProduction, logoutUri); window.location.assign(url); } } resetError() { this._error.set(null); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: UaePassAuthService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: UaePassAuthService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: UaePassAuthService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [] }); class UaePassLoginButtonComponent { auth = inject(UaePassAuthService); config = inject(UAE_PASS_CONFIG); // Default asset paths for UAE Pass button defaultAssetPaths = { english: 'assets/UAEPASS_Sign_with_Btn_Outline_Active@2x.svg', arabic: 'assets/UAEPASS_Sign_with_Btn_Outline_Active_AR@2x.svg', }; // Inputs language = input(); customImageSrc = input(null); customStyles = input(''); isDisabled = input(false); // Outputs pressed = output(); // Computed properties isBusy = computed(() => { const s = this.auth.status(); return (s === UaePassAuthStatus.Authorizing || s === UaePassAuthStatus.ExchangingToken); }); imageSrc = computed(() => { console.log('Input language:', this.language()); console.log('Config language:', this.config.language); if (this.customImageSrc()) { return this.customImageSrc(); } // Use input language first, then config language, then default to 'en' const configLang = this.config.language; const inputLang = this.language(); // Handle the case where config.language might be 'ar' string or enum value let normalizedConfigLang = 'en'; if (configLang === 'ar' || configLang === 'Arabic' || String(configLang).toLowerCase() === 'ar') { normalizedConfigLang = 'ar'; } const lang = inputLang || normalizedConfigLang; console.log('Final language used:', lang); console.log('Config language raw:', configLang); console.log('Normalized config language:', normalizedConfigLang); const configLogos = this.config.buttonLogos; if (configLogos) { console.log('Config logos:', configLogos); if (lang === 'ar' && configLogos.arabic) { console.log('Using config Arabic logo:', configLogos.arabic); return configLogos.arabic; } if (lang === 'en' && configLogos.english) { console.log('Using config English logo:', configLogos.english); return configLogos.english; } } // Fallback to default asset paths const finalImage = lang === 'ar' ? this.defaultAssetPaths.arabic : this.defaultAssetPaths.english; console.log('Using default asset path:', finalImage); return finalImage; }); disabled = computed(() => { return this.isDisabled() || this.isBusy(); }); handleLogin() { this.pressed.emit(); this.auth.redirectToAuthorization(); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: UaePassLoginButtonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "19.2.14", type: UaePassLoginButtonComponent, isStandalone: true, selector: "uae-pass-login-button", inputs: { language: { classPropertyName: "language", publicName: "language", isSignal: true, isRequired: false, transformFunction: null }, customImageSrc: { classPropertyName: "customImageSrc", publicName: "customImageSrc", isSignal: true, isRequired: false, transformFunction: null }, customStyles: { classPropertyName: "customStyles", publicName: "customStyles", isSignal: true, isRequired: false, transformFunction: null }, isDisabled: { classPropertyName: "isDisabled", publicName: "isDisabled", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { pressed: "pressed" }, host: { classAttribute: "uaepass-login-button" }, ngImport: i0, template: ` <button type="button" class="uae-pass-login-btn" [disabled]="disabled()" (click)="handleLogin()" [attr.aria-label]="'Sign in with UAE Pass'" > <img [src]="imageSrc()" alt="Sign with UAE PASS" class="uae-pass-btn-image" [class.loading]="disabled()" [style]="customStyles()" /> <div class="loading-spinner" *ngIf="disabled()"></div> </button> `, isInline: true, styles: [":host{display:block;width:100%}.uae-pass-login-btn{display:block;width:100%;border:none;background:transparent;padding:0;cursor:pointer;position:relative;transition:all .3s ease}.uae-pass-btn-image{width:100%;height:auto;display:block;transition:all .3s ease}.uae-pass-login-btn:hover:not([disabled]) .uae-pass-btn-image{transform:translateY(-2px);filter:drop-shadow(0 6px 20px rgba(0,0,0,.15))}.uae-pass-login-btn:active:not([disabled]) .uae-pass-btn-image{transform:translateY(0);filter:drop-shadow(0 2px 8px rgba(0,0,0,.1))}.uae-pass-login-btn[disabled] .uae-pass-btn-image{opacity:.6;cursor:not-allowed}.uae-pass-btn-image.loading{opacity:.6}.loading-spinner{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:24px;height:24px;border:3px solid rgba(0,0,0,.1);border-top:3px solid #1e40af;border-radius:50%;animation:spin 1s linear infinite}@keyframes spin{0%{transform:translate(-50%,-50%) rotate(0)}to{transform:translate(-50%,-50%) rotate(360deg)}}@media (max-width: 768px){.uae-pass-btn-image{max-width:280px;margin:0 auto}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: UaePassLoginButtonComponent, decorators: [{ type: Component, args: [{ selector: 'uae-pass-login-button', standalone: true, imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'uaepass-login-button', }, template: ` <button type="button" class="uae-pass-login-btn" [disabled]="disabled()" (click)="handleLogin()" [attr.aria-label]="'Sign in with UAE Pass'" > <img [src]="imageSrc()" alt="Sign with UAE PASS" class="uae-pass-btn-image" [class.loading]="disabled()" [style]="customStyles()" /> <div class="loading-spinner" *ngIf="disabled()"></div> </button> `, styles: [":host{display:block;width:100%}.uae-pass-login-btn{display:block;width:100%;border:none;background:transparent;padding:0;cursor:pointer;position:relative;transition:all .3s ease}.uae-pass-btn-image{width:100%;height:auto;display:block;transition:all .3s ease}.uae-pass-login-btn:hover:not([disabled]) .uae-pass-btn-image{transform:translateY(-2px);filter:drop-shadow(0 6px 20px rgba(0,0,0,.15))}.uae-pass-login-btn:active:not([disabled]) .uae-pass-btn-image{transform:translateY(0);filter:drop-shadow(0 2px 8px rgba(0,0,0,.1))}.uae-pass-login-btn[disabled] .uae-pass-btn-image{opacity:.6;cursor:not-allowed}.uae-pass-btn-image.loading{opacity:.6}.loading-spinner{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:24px;height:24px;border:3px solid rgba(0,0,0,.1);border-top:3px solid #1e40af;border-radius:50%;animation:spin 1s linear infinite}@keyframes spin{0%{transform:translate(-50%,-50%) rotate(0)}to{transform:translate(-50%,-50%) rotate(360deg)}}@media (max-width: 768px){.uae-pass-btn-image{max-width:280px;margin:0 auto}}\n"] }] }] }); class UaePassCallbackComponent { auth = inject(UaePassAuthService); config = inject(UAE_PASS_CONFIG); Status = UaePassAuthStatus; // Optional current URL input (useful for SSR or router-less contexts) url = input(null); // Outputs for host app to react success = output(); failed = output(); status = this.auth.status; error = this.auth.error; // Localized texts texts = computed(() => { const lang = this.config.language || 'en'; return getUaePassTexts(lang); }); // RTL support isRTL = computed(() => { const lang = this.config.language || 'en'; return lang === 'ar'; }); // React to status changes _fx = effect(() => { const s = this.auth.status(); if (s === UaePassAuthStatus.Authenticated) { this.success.emit(); // Auto-redirect to home after successful authentication setTimeout(() => { if (typeof window !== 'undefined') { window.location.href = '/'; } }, 2000); } if (s === UaePassAuthStatus.Error) { const msg = this.auth.error(); if (msg) this.failed.emit(msg); } }); constructor() { // Only handle callback if we're actually on the callback route with a code parameter const currentUrl = this.url() ?? (typeof window !== 'undefined' ? window.location.href : ''); if (currentUrl && (currentUrl.includes('code=') || currentUrl.includes('error='))) { // No await to avoid blocking change detection; service sets signals when done void this.auth.handleRedirectCallback(currentUrl); } else { // If no OAuth parameters, redirect to home immediately if (typeof window !== 'undefined') { window.location.href = '/'; } } } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: UaePassCallbackComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.14", type: UaePassCallbackComponent, isStandalone: true, selector: "uae-pass-callback", inputs: { url: { classPropertyName: "url", publicName: "url", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { success: "success", failed: "failed" }, host: { classAttribute: "uaepass-callback" }, ngImport: i0, template: ` <div class="callback-container" [attr.dir]="isRTL() ? 'rtl' : 'ltr'"> @if (status() === Status.ExchangingToken || status() === Status.Authorizing) { <div class="loading-state"> <div class="spinner-container"> <div class="spinner"></div> </div> <h2 class="status-title">{{ texts().uaePass }}</h2> <p class="status-message" aria-live="polite">{{ texts().completingSignIn }}</p> <div class="progress-bar"> <div class="progress-fill"></div> </div> </div> } @else if (status() === Status.Authenticated) { <div class="success-state"> <div class="success-icon"> <svg width="64" height="64" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <circle cx="12" cy="12" r="10" fill="#10b981"/> <path d="m9 12 2 2 4-4" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> </svg> </div> <h2 class="status-title success">{{ texts().signedInSuccessfully }}</h2> <p class="status-message" aria-live="polite">{{ texts().redirectingToHomePage }}</p> <div class="countdown-dots"> <span class="dot"></span> <span class="dot"></span> <span class="dot"></span> </div> </div> } @else if (status() === Status.Error) { <div class="error-state"> <div class="error-icon"> <svg width="64" height="64" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <circle cx="12" cy="12" r="10" fill="#ef4444"/> <path d="m15 9-6 6m0-6 6 6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> </svg> </div> <h2 class="status-title error">{{ texts().error }}</h2> <p class="error-message" role="alert">{{ error() }}</p> <a href="/" class="return-button">{{ texts().returnToHome }}</a> </div> } @else { <div class="loading-state"> <div class="spinner-container"> <div class="spinner"></div> </div> <h2 class="status-title">{{ texts().uaePass }}</h2> <p class="status-message">{{ texts().redirectingToHomePage }}</p> </div> } </div> `, isInline: true, styles: [":host{display:block;min-height:100vh;background:linear-gradient(135deg,#667eea,#764ba2);font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif}.callback-container{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:2rem;text-align:center}.loading-state,.success-state,.error-state{background:#fffffff2;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border-radius:24px;padding:3rem 2rem;max-width:400px;width:100%;box-shadow:0 20px 40px #0000001a;border:1px solid rgba(255,255,255,.2)}.spinner-container{margin-bottom:2rem}.spinner{width:64px;height:64px;border:4px solid #e5e7eb;border-top:4px solid #1e40af;border-radius:50%;animation:spin 1s linear infinite;margin:0 auto}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.status-title{font-size:1.5rem;font-weight:700;color:#1f2937;margin:0 0 1rem;line-height:1.3}.status-title.success{color:#059669}.status-title.error{color:#dc2626}.status-message{font-size:1rem;color:#6b7280;margin:0 0 2rem;line-height:1.5}.error-message{font-size:.95rem;color:#dc2626;margin:0 0 2rem;padding:1rem;background:#fef2f2;border:1px solid #fecaca;border-radius:12px;line-height:1.5}.progress-bar{width:100%;height:4px;background:#e5e7eb;border-radius:2px;overflow:hidden}.progress-fill{height:100%;background:linear-gradient(90deg,#1e40af,#3b82f6);border-radius:2px;animation:progress 2s ease-in-out infinite}@keyframes progress{0%{width:0%}50%{width:70%}to{width:100%}}.success-icon,.error-icon{margin-bottom:1.5rem;animation:scaleIn .5s ease-out}@keyframes scaleIn{0%{transform:scale(0)}to{transform:scale(1)}}.countdown-dots{display:flex;justify-content:center;gap:.5rem;margin-top:1rem}.dot{width:8px;height:8px;background:#9ca3af;border-radius:50%;animation:pulse 1.5s ease-in-out infinite}.dot:nth-child(2){animation-delay:.3s}.dot:nth-child(3){animation-delay:.6s}@keyframes pulse{0%,to{opacity:.3}50%{opacity:1}}.return-button{display:inline-block;background:#1e40af;color:#fff;text-decoration:none;padding:.75rem 2rem;border-radius:12px;font-weight:600;transition:all .3s ease;border:none;cursor:pointer}.return-button:hover{background:#1d4ed8;transform:translateY(-2px);box-shadow:0 8px 20px #1e40af4d}.return-button:active{transform:translateY(0)}[dir=rtl] .callback-container{font-family:Noto Sans Arabic,Inter,-apple-system,BlinkMacSystemFont,sans-serif}[dir=rtl] .status-title,[dir=rtl] .status-message,[dir=rtl] .error-message{text-align:right}@media (max-width: 768px){.callback-container{padding:1rem}.loading-state,.success-state,.error-state{padding:2rem 1.5rem;border-radius:16px}.status-title{font-size:1.25rem}.spinner{width:48px;height:48px}}@media (prefers-color-scheme: dark){:host{background:linear-gradient(135deg,#1e293b,#334155)}.loading-state,.success-state,.error-state{background:#0f172af2;border:1px solid rgba(255,255,255,.1)}.status-title{color:#f8fafc}.status-message{color:#cbd5e1}.spinner{border-color:#475569;border-top-color:#3b82f6}.progress-bar{background:#475569}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: UaePassCallbackComponent, decorators: [{ type: Component, args: [{ selector: 'uae-pass-callback', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'uaepass-callback' }, template: ` <div class="callback-container" [attr.dir]="isRTL() ? 'rtl' : 'ltr'"> @if (status() === Status.ExchangingToken || status() === Status.Authorizing) { <div class="loading-state"> <div class="spinner-container"> <div class="spinner"></div> </div> <h2 class="status-title">{{ texts().uaePass }}</h2> <p class="status-message" aria-live="polite">{{ texts().completingSignIn }}</p> <div class="progress-bar"> <div class="progress-fill"></div> </div> </div> } @else if (status() === Status.Authenticated) { <div class="success-state"> <div class="success-icon"> <svg width="64" height="64" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <circle cx="12" cy="12" r="10" fill="#10b981"/> <path d="m9 12 2 2 4-4" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> </svg> </div> <h2 class="status-title success">{{ texts().signedInSuccessfully }}</h2> <p class="status-message" aria-live="polite">{{ texts().redirectingToHomePage }}</p> <div class="countdown-dots"> <span class="dot"></span> <span class="dot"></span> <span class="dot"></span> </div> </div> } @else if (status() === Status.Error) { <div class="error-state"> <div class="error-icon"> <svg width="64" height="64" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <circle cx="12" cy="12" r="10" fill="#ef4444"/> <path d="m15 9-6 6m0-6 6 6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> </svg> </div> <h2 class="status-title error">{{ texts().error }}</h2> <p class="error-message" role="alert">{{ error() }}</p> <a href="/" class="return-button">{{ texts().returnToHome }}</a> </div> } @else { <div class="loading-state"> <div class="spinner-container"> <div class="spinner"></div> </div> <h2 class="status-title">{{ texts().uaePass }}</h2> <p class="status-message">{{ texts().redirectingToHomePage }}</p> </div> } </div> `, styles: [":host{display:block;min-height:100vh;background:linear-gradient(135deg,#667eea,#764ba2);font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif}.callback-container{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:2rem;text-align:center}.loading-state,.success-state,.error-state{background:#fffffff2;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border-radius:24px;padding:3rem 2rem;max-width:400px;width:100%;box-shadow:0 20px 40px #0000001a;border:1px solid rgba(255,255,255,.2)}.spinner-container{margin-bottom:2rem}.spinner{width:64px;height:64px;border:4px solid #e5e7eb;border-top:4px solid #1e40af;border-radius:50%;animation:spin 1s linear infinite;margin:0 auto}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.status-title{font-size:1.5rem;font-weight:700;color:#1f2937;margin:0 0 1rem;line-height:1.3}.status-title.success{color:#059669}.status-title.error{color:#dc2626}.status-message{font-size:1rem;color:#6b7280;margin:0 0 2rem;line-height:1.5}.error-message{font-size:.95rem;color:#dc2626;margin:0 0 2rem;padding:1rem;background:#fef2f2;border:1px solid #fecaca;border-radius:12px;line-height:1.5}.progress-bar{width:100%;height:4px;background:#e5e7eb;border-radius:2px;overflow:hidden}.progress-fill{height:100%;background:linear-gradient(90deg,#1e40af,#3b82f6);border-radius:2px;animation:progress 2s ease-in-out infinite}@keyframes progress{0%{width:0%}50%{width:70%}to{width:100%}}.success-icon,.error-icon{margin-bottom:1.5rem;animation:scaleIn .5s ease-out}@keyframes scaleIn{0%{transform:scale(0)}to{transform:scale(1)}}.countdown-dots{display:flex;justify-content:center;gap:.5rem;margin-top:1rem}.dot{width:8px;height:8px;background:#9ca3af;border-radius:50%;animation:pulse 1.5s ease-in-out infinite}.dot:nth-child(2){animation-delay:.3s}.dot:nth-child(3){animation-delay:.6s}@keyframes pulse{0%,to{opacity:.3}50%{opacity:1}}.return-button{display:inline-block;background:#1e40af;color:#fff;text-decoration:none;padding:.75rem 2rem;border-radius:12px;font-weight:600;transition:all .3s ease;border:none;cursor:pointer}.return-button:hover{background:#1d4ed8;transform:translateY(-2px);box-shadow:0 8px 20px #1e40af4d}.return-button:active{transform:translateY(0)}[dir=rtl] .callback-container{font-family:Noto Sans Arabic,Inter,-apple-system,BlinkMacSystemFont,sans-serif}[dir=rtl] .status-title,[dir=rtl] .status-message,[dir=rtl] .error-message{text-align:right}@media (max-width: 768px){.callback-container{padding:1rem}.loading-state,.success-state,.error-state{padding:2rem 1.5rem;border-radius:16px}.status-title{font-size:1.25rem}.spinner{width:48px;height:48px}}@media (prefers-color-scheme: dark){:host{background:linear-gradient(135deg,#1e293b,#334155)}.loading-state,.success-state,.error-state{background:#0f172af2;border:1px solid rgba(255,255,255,.1)}.status-title{color:#f8fafc}.status-message{color:#cbd5e1}.spinner{border-color:#475569;border-top-color:#3b82f6}.progress-bar{background:#475569}}\n"] }] }], ctorParameters: () => [] }); /* * Public API surface of sensei-uaepass */ // Configuration and types /** * Generated bundle index. Do not edit. */ export { CodeChallengeMethod, OAuthGrantType, OAuthResponseType, UAE_PASS_ACR, UAE_PASS_BASE_URL, UAE_PASS_CONFIG, UaePassAcr, UaePassAuthService, UaePassAuthStatus, UaePassCallbackComponent, UaePassEnvironment, UaePassLanguageCode, UaePassLoginButtonComponent, UaePassStorageMode, authorizeUrl, baseUrl, logoutUrl, provideUaePass, tokenUrl, userInfoUrl }; //# sourceMappingURL=sensei-uaepass.mjs.map