@progalaxyelabs/ngx-stonescriptphp-client 1.22.1 → 1.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, makeEnvironmentProviders, Injectable, Inject, Optional, EventEmitter, Output, Input, Component, input, output, signal, computed } from '@angular/core';
2
+ import { InjectionToken, makeEnvironmentProviders, Injectable, Inject, Optional, EventEmitter, Output, Input, ViewChildren, Component, input, output, signal, computed } from '@angular/core';
3
3
  import { BehaviorSubject } from 'rxjs';
4
4
  import * as i3 from '@angular/common';
5
5
  import { CommonModule } from '@angular/common';
@@ -777,6 +777,108 @@ class ProgalaxyElabsAuth {
777
777
  return { exists: false };
778
778
  }
779
779
  }
780
+ // -- OTP authentication --------------------------------------------------
781
+ async sendOtp(identifier) {
782
+ try {
783
+ const response = await fetch(`${this.host}/api/auth/otp/send`, {
784
+ method: 'POST',
785
+ headers: { 'Content-Type': 'application/json' },
786
+ body: JSON.stringify({ identifier })
787
+ });
788
+ const data = await response.json();
789
+ if (!response.ok) {
790
+ return {
791
+ success: false,
792
+ identifier_type: 'email',
793
+ masked_identifier: '',
794
+ expires_in: 0,
795
+ resend_after: 0,
796
+ ...data
797
+ };
798
+ }
799
+ return {
800
+ success: data.success ?? true,
801
+ identifier_type: data.identifier_type,
802
+ masked_identifier: data.masked_identifier,
803
+ expires_in: data.expires_in,
804
+ resend_after: data.resend_after
805
+ };
806
+ }
807
+ catch {
808
+ return {
809
+ success: false,
810
+ identifier_type: 'email',
811
+ masked_identifier: '',
812
+ expires_in: 0,
813
+ resend_after: 0
814
+ };
815
+ }
816
+ }
817
+ async verifyOtp(identifier, code) {
818
+ try {
819
+ const response = await fetch(`${this.host}/api/auth/otp/verify`, {
820
+ method: 'POST',
821
+ headers: { 'Content-Type': 'application/json' },
822
+ body: JSON.stringify({ identifier, code })
823
+ });
824
+ const data = await response.json();
825
+ if (!response.ok) {
826
+ return { success: false, verified_token: '', ...data };
827
+ }
828
+ return {
829
+ success: data.success ?? true,
830
+ verified_token: data.verified_token
831
+ };
832
+ }
833
+ catch {
834
+ return { success: false, verified_token: '' };
835
+ }
836
+ }
837
+ async identityLogin(verifiedToken) {
838
+ try {
839
+ const response = await fetch(`${this.host}/api/identity/login`, {
840
+ method: 'POST',
841
+ headers: { 'Content-Type': 'application/json' },
842
+ body: JSON.stringify({
843
+ verified_token: verifiedToken,
844
+ platform: this.config.platformCode
845
+ })
846
+ });
847
+ // 404 means no identity found — caller should show registration form
848
+ if (response.status === 404) {
849
+ return { success: false, message: 'identity_not_found' };
850
+ }
851
+ const data = await response.json();
852
+ if (!response.ok) {
853
+ return { success: false, message: data.error || data.message || 'Login failed' };
854
+ }
855
+ return this.handleLoginResponse(data);
856
+ }
857
+ catch {
858
+ return { success: false, message: 'Network error. Please try again.' };
859
+ }
860
+ }
861
+ async identityRegister(verifiedToken, displayName) {
862
+ try {
863
+ const response = await fetch(`${this.host}/api/identity/register`, {
864
+ method: 'POST',
865
+ headers: { 'Content-Type': 'application/json' },
866
+ body: JSON.stringify({
867
+ verified_token: verifiedToken,
868
+ display_name: displayName,
869
+ platform: this.config.platformCode
870
+ })
871
+ });
872
+ const data = await response.json();
873
+ if (!response.ok) {
874
+ return { success: false, message: data.error || data.message || 'Registration failed' };
875
+ }
876
+ return this.handleLoginResponse(data);
877
+ }
878
+ catch {
879
+ return { success: false, message: 'Network error. Please try again.' };
880
+ }
881
+ }
780
882
  // -- OAuth ----------------------------------------------------------------
781
883
  async loginWithProvider(provider) {
782
884
  return new Promise((resolve) => {
@@ -877,7 +979,8 @@ class ProgalaxyElabsAuth {
877
979
  if (!raw)
878
980
  return undefined;
879
981
  return {
880
- email: raw.email,
982
+ email: raw.email ?? '',
983
+ phone: raw.phone,
881
984
  display_name: raw.display_name ?? raw.email?.split('@')[0] ?? '',
882
985
  photo_url: raw.photo_url ?? raw.picture,
883
986
  is_email_verified: raw.is_email_verified ?? false
@@ -1201,6 +1304,37 @@ class AuthService {
1201
1304
  getCurrentUser() {
1202
1305
  return this.userSubject.value;
1203
1306
  }
1307
+ // ── OTP authentication ─────────────────────────────────────────────────────
1308
+ async sendOtp(identifier) {
1309
+ if (!this.plugin.sendOtp) {
1310
+ return { success: false, identifier_type: 'email', masked_identifier: '', expires_in: 0, resend_after: 0 };
1311
+ }
1312
+ return this.plugin.sendOtp(identifier);
1313
+ }
1314
+ async verifyOtp(identifier, code) {
1315
+ if (!this.plugin.verifyOtp) {
1316
+ return { success: false, verified_token: '' };
1317
+ }
1318
+ return this.plugin.verifyOtp(identifier, code);
1319
+ }
1320
+ async identityLogin(verifiedToken) {
1321
+ if (!this.plugin.identityLogin) {
1322
+ return { success: false, message: 'OTP login not supported by the configured auth plugin' };
1323
+ }
1324
+ const result = await this.plugin.identityLogin(verifiedToken);
1325
+ if (result.success)
1326
+ this.storeAuthResult(result);
1327
+ return result;
1328
+ }
1329
+ async identityRegister(verifiedToken, displayName) {
1330
+ if (!this.plugin.identityRegister) {
1331
+ return { success: false, message: 'OTP registration not supported by the configured auth plugin' };
1332
+ }
1333
+ const result = await this.plugin.identityRegister(verifiedToken, displayName);
1334
+ if (result.success)
1335
+ this.storeAuthResult(result);
1336
+ return result;
1337
+ }
1204
1338
  // ── Multi-tenant operations ───────────────────────────────────────────────
1205
1339
  async getTenantMemberships(serverName) {
1206
1340
  if (!this.plugin.getTenantMemberships)
@@ -2003,6 +2137,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
2003
2137
  class TenantLoginComponent {
2004
2138
  auth;
2005
2139
  providerRegistry;
2140
+ otpInputs;
2006
2141
  // Component Configuration
2007
2142
  title = 'Sign In';
2008
2143
  providers = ['google'];
@@ -2032,6 +2167,22 @@ class TenantLoginComponent {
2032
2167
  showPassword = false;
2033
2168
  useOAuth = true;
2034
2169
  oauthProviders = [];
2170
+ // Effective OAuth providers (filtered for Android WebView)
2171
+ effectiveOauthProviders = [];
2172
+ // OTP State
2173
+ otpActive = false;
2174
+ otpStep = 'identifier';
2175
+ otpIdentifier = '';
2176
+ otpIdentifierHint = '';
2177
+ otpNormalizedIdentifier = ''; // E.164 for phone, as-is for email
2178
+ otpMaskedIdentifier = '';
2179
+ otpDigits = ['', '', '', '', '', ''];
2180
+ otpVerifiedToken = '';
2181
+ otpDisplayName = '';
2182
+ otpResendCountdown = 0;
2183
+ otpResendTimer = null;
2184
+ // Android WebView detection
2185
+ isAndroidWebView = false;
2035
2186
  // Tenant Selection State
2036
2187
  showingTenantSelector = false;
2037
2188
  memberships = [];
@@ -2046,17 +2197,32 @@ class TenantLoginComponent {
2046
2197
  this.error = 'Configuration Error: No authentication providers specified.';
2047
2198
  throw new Error('TenantLoginComponent requires providers input.');
2048
2199
  }
2049
- this.oauthProviders = this.providers.filter(p => p !== 'emailPassword');
2050
- // If only emailPassword is available, use it by default
2051
- if (this.oauthProviders.length === 0 && this.isProviderEnabled('emailPassword')) {
2200
+ // Detect Android WebView
2201
+ this.isAndroidWebView = /wv|Android.*Version\//.test(navigator.userAgent);
2202
+ // Filter out 'otp' and 'emailPassword' for OAuth list
2203
+ this.oauthProviders = this.providers.filter(p => p !== 'emailPassword' && p !== 'otp');
2204
+ // Auto-hide Google on Android WebView
2205
+ this.effectiveOauthProviders = this.isAndroidWebView
2206
+ ? this.oauthProviders.filter(p => p !== 'google')
2207
+ : [...this.oauthProviders];
2208
+ // If OTP is configured, it becomes the primary login method
2209
+ if (this.isProviderEnabled('otp')) {
2210
+ this.otpActive = true;
2211
+ }
2212
+ // If only emailPassword is available (no OTP, no OAuth), use it by default
2213
+ if (!this.otpActive && this.effectiveOauthProviders.length === 0 && this.isProviderEnabled('emailPassword')) {
2052
2214
  this.useOAuth = false;
2053
2215
  }
2054
2216
  // Prefill email if provided (for account linking flow)
2055
2217
  if (this.prefillEmail) {
2056
2218
  this.email = this.prefillEmail;
2057
- this.useOAuth = false; // Switch to email/password form
2219
+ this.useOAuth = false;
2220
+ this.otpActive = false; // Switch to email/password form for linking
2058
2221
  }
2059
2222
  }
2223
+ ngOnDestroy() {
2224
+ this.clearResendTimer();
2225
+ }
2060
2226
  isProviderEnabled(provider) {
2061
2227
  return this.providers.includes(provider);
2062
2228
  }
@@ -2237,6 +2403,254 @@ class TenantLoginComponent {
2237
2403
  this.loading = false;
2238
2404
  }
2239
2405
  }
2406
+ // ── OTP methods ────────────────────────────────────────────────────────────
2407
+ /** Detect identifier type from input and show hint */
2408
+ onOtpIdentifierChange() {
2409
+ const value = this.otpIdentifier.trim();
2410
+ if (!value) {
2411
+ this.otpIdentifierHint = '';
2412
+ return;
2413
+ }
2414
+ const detected = this.detectIdentifierType(value);
2415
+ if (detected === 'email') {
2416
+ this.otpIdentifierHint = 'OTP will be sent to this email';
2417
+ }
2418
+ else if (detected === 'phone') {
2419
+ const digits = value.replace(/\D/g, '');
2420
+ if (digits.length === 10) {
2421
+ this.otpIdentifierHint = 'OTP will be sent to +91 ' + digits;
2422
+ }
2423
+ else {
2424
+ this.otpIdentifierHint = 'OTP will be sent to this number';
2425
+ }
2426
+ }
2427
+ else {
2428
+ this.otpIdentifierHint = '';
2429
+ }
2430
+ }
2431
+ /** Send OTP to the entered identifier */
2432
+ async onOtpSend() {
2433
+ const raw = this.otpIdentifier.trim();
2434
+ if (!raw) {
2435
+ this.error = 'Please enter your email or phone number';
2436
+ return;
2437
+ }
2438
+ const type = this.detectIdentifierType(raw);
2439
+ if (!type) {
2440
+ this.error = 'Please enter a valid email address or phone number';
2441
+ return;
2442
+ }
2443
+ // Normalize: auto-prepend +91 for 10-digit Indian numbers
2444
+ if (type === 'phone') {
2445
+ const digits = raw.replace(/\D/g, '');
2446
+ this.otpNormalizedIdentifier = digits.length === 10 ? `+91${digits}` : `+${digits}`;
2447
+ }
2448
+ else {
2449
+ this.otpNormalizedIdentifier = raw;
2450
+ }
2451
+ this.loading = true;
2452
+ this.error = '';
2453
+ try {
2454
+ const result = await this.auth.sendOtp(this.otpNormalizedIdentifier);
2455
+ if (!result.success) {
2456
+ this.error = 'Failed to send OTP. Please try again.';
2457
+ return;
2458
+ }
2459
+ this.otpMaskedIdentifier = result.masked_identifier;
2460
+ this.otpDigits = ['', '', '', '', '', ''];
2461
+ this.otpStep = 'code';
2462
+ this.startResendCountdown(result.resend_after || 60);
2463
+ // Focus first digit input after view update
2464
+ setTimeout(() => this.focusOtpInput(0), 50);
2465
+ }
2466
+ catch (err) {
2467
+ this.error = err.message || 'Failed to send OTP';
2468
+ }
2469
+ finally {
2470
+ this.loading = false;
2471
+ }
2472
+ }
2473
+ /** Verify the entered OTP code */
2474
+ async onOtpVerify() {
2475
+ if (this.loading)
2476
+ return;
2477
+ const code = this.otpCode;
2478
+ if (code.length < 6) {
2479
+ this.error = 'Please enter the complete 6-digit code';
2480
+ return;
2481
+ }
2482
+ this.loading = true;
2483
+ this.error = '';
2484
+ try {
2485
+ const verifyResult = await this.auth.verifyOtp(this.otpNormalizedIdentifier, code);
2486
+ if (!verifyResult.success) {
2487
+ this.error = 'Invalid code. Please try again.';
2488
+ return;
2489
+ }
2490
+ this.otpVerifiedToken = verifyResult.verified_token;
2491
+ // Auto-attempt login
2492
+ const loginResult = await this.auth.identityLogin(this.otpVerifiedToken);
2493
+ if (loginResult.success) {
2494
+ // Existing user — proceed with standard post-auth flow
2495
+ await this.handlePostAuthFlow(loginResult);
2496
+ return;
2497
+ }
2498
+ if (loginResult.message === 'identity_not_found') {
2499
+ // New user — show registration form
2500
+ this.otpStep = 'register';
2501
+ return;
2502
+ }
2503
+ // Other login error
2504
+ this.error = loginResult.message || 'Login failed. Please try again.';
2505
+ }
2506
+ catch (err) {
2507
+ this.error = err.message || 'Verification failed';
2508
+ }
2509
+ finally {
2510
+ this.loading = false;
2511
+ }
2512
+ }
2513
+ /** Register a new identity after OTP verification */
2514
+ async onOtpRegister() {
2515
+ const name = this.otpDisplayName.trim();
2516
+ if (!name) {
2517
+ this.error = 'Please enter your name';
2518
+ return;
2519
+ }
2520
+ this.loading = true;
2521
+ this.error = '';
2522
+ try {
2523
+ const result = await this.auth.identityRegister(this.otpVerifiedToken, name);
2524
+ if (!result.success) {
2525
+ // If token expired, restart OTP flow
2526
+ if (result.message?.includes('expired') || result.message?.includes('Invalid')) {
2527
+ this.error = 'Session expired. Please verify again.';
2528
+ this.otpStep = 'identifier';
2529
+ this.otpVerifiedToken = '';
2530
+ this.otpDisplayName = '';
2531
+ return;
2532
+ }
2533
+ this.error = result.message || 'Registration failed';
2534
+ return;
2535
+ }
2536
+ // New identity created — emit onboarding event
2537
+ const identifierType = this.detectIdentifierType(this.otpIdentifier.trim());
2538
+ this.needsOnboarding.emit({
2539
+ auth_method: 'otp',
2540
+ is_new_identity: true,
2541
+ identity: {
2542
+ email: identifierType === 'email' ? this.otpNormalizedIdentifier : '',
2543
+ phone: identifierType === 'phone' ? this.otpNormalizedIdentifier : undefined,
2544
+ display_name: name,
2545
+ },
2546
+ });
2547
+ }
2548
+ catch (err) {
2549
+ this.error = err.message || 'Registration failed';
2550
+ }
2551
+ finally {
2552
+ this.loading = false;
2553
+ }
2554
+ }
2555
+ /** Resend the OTP */
2556
+ onOtpResend(event) {
2557
+ event.preventDefault();
2558
+ this.onOtpSend();
2559
+ }
2560
+ /** Go back to identifier entry */
2561
+ onOtpBack(event) {
2562
+ event.preventDefault();
2563
+ this.otpStep = 'identifier';
2564
+ this.otpDigits = ['', '', '', '', '', ''];
2565
+ this.otpVerifiedToken = '';
2566
+ this.error = '';
2567
+ this.clearResendTimer();
2568
+ }
2569
+ // ── OTP digit input handling ─────────────────────────────────────────────
2570
+ onOtpDigitInput(event, index) {
2571
+ const input = event.target;
2572
+ const value = input.value.replace(/\D/g, '');
2573
+ this.otpDigits[index] = value ? value[0] : '';
2574
+ input.value = this.otpDigits[index];
2575
+ // Auto-advance to next input
2576
+ if (value && index < 5) {
2577
+ this.focusOtpInput(index + 1);
2578
+ }
2579
+ // Auto-verify when all 6 digits entered
2580
+ if (this.otpCode.length === 6) {
2581
+ this.onOtpVerify();
2582
+ }
2583
+ }
2584
+ onOtpDigitKeydown(event, index) {
2585
+ if (event.key === 'Backspace' && !this.otpDigits[index] && index > 0) {
2586
+ this.focusOtpInput(index - 1);
2587
+ }
2588
+ }
2589
+ onOtpPaste(event) {
2590
+ event.preventDefault();
2591
+ const pasted = (event.clipboardData?.getData('text') || '').replace(/\D/g, '').slice(0, 6);
2592
+ for (let i = 0; i < 6; i++) {
2593
+ this.otpDigits[i] = pasted[i] || '';
2594
+ }
2595
+ // Update all input elements
2596
+ const inputs = this.otpInputs?.toArray();
2597
+ if (inputs) {
2598
+ for (let i = 0; i < 6; i++) {
2599
+ inputs[i].nativeElement.value = this.otpDigits[i];
2600
+ }
2601
+ }
2602
+ if (pasted.length >= 6) {
2603
+ this.onOtpVerify();
2604
+ }
2605
+ else {
2606
+ this.focusOtpInput(Math.min(pasted.length, 5));
2607
+ }
2608
+ }
2609
+ get otpCode() {
2610
+ return this.otpDigits.join('');
2611
+ }
2612
+ // ── OTP helpers ──────────────────────────────────────────────────────────
2613
+ detectIdentifierType(value) {
2614
+ if (value.includes('@')) {
2615
+ // Basic email validation
2616
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? 'email' : null;
2617
+ }
2618
+ const digits = value.replace(/\D/g, '');
2619
+ if (digits.length >= 10 && digits.length <= 15) {
2620
+ return 'phone';
2621
+ }
2622
+ return null;
2623
+ }
2624
+ focusOtpInput(index) {
2625
+ const inputs = this.otpInputs?.toArray();
2626
+ if (inputs && inputs[index]) {
2627
+ inputs[index].nativeElement.focus();
2628
+ inputs[index].nativeElement.select();
2629
+ }
2630
+ }
2631
+ startResendCountdown(seconds) {
2632
+ this.clearResendTimer();
2633
+ this.otpResendCountdown = seconds;
2634
+ this.otpResendTimer = setInterval(() => {
2635
+ this.otpResendCountdown--;
2636
+ if (this.otpResendCountdown <= 0) {
2637
+ this.clearResendTimer();
2638
+ }
2639
+ }, 1000);
2640
+ }
2641
+ clearResendTimer() {
2642
+ if (this.otpResendTimer) {
2643
+ clearInterval(this.otpResendTimer);
2644
+ this.otpResendTimer = null;
2645
+ }
2646
+ this.otpResendCountdown = 0;
2647
+ }
2648
+ formatCountdown(seconds) {
2649
+ const m = Math.floor(seconds / 60);
2650
+ const s = seconds % 60;
2651
+ return `${m}:${s.toString().padStart(2, '0')}`;
2652
+ }
2653
+ // ── Formatting helpers ───────────────────────────────────────────────────
2240
2654
  formatRole(role) {
2241
2655
  return role.charAt(0).toUpperCase() + role.slice(1);
2242
2656
  }
@@ -2265,14 +2679,125 @@ class TenantLoginComponent {
2265
2679
  this.createTenant.emit();
2266
2680
  }
2267
2681
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: TenantLoginComponent, deps: [{ token: AuthService }, { token: ProviderRegistryService }], target: i0.ɵɵFactoryTarget.Component });
2268
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.17", type: TenantLoginComponent, isStandalone: true, selector: "lib-tenant-login", inputs: { title: "title", providers: "providers", showTenantSelector: "showTenantSelector", autoSelectSingleTenant: "autoSelectSingleTenant", prefillEmail: "prefillEmail", allowTenantCreation: "allowTenantCreation", tenantSelectorTitle: "tenantSelectorTitle", tenantSelectorDescription: "tenantSelectorDescription", continueButtonText: "continueButtonText", registerLinkText: "registerLinkText", registerLinkAction: "registerLinkAction", createTenantLinkText: "createTenantLinkText", createTenantLinkAction: "createTenantLinkAction" }, outputs: { tenantSelected: "tenantSelected", needsOnboarding: "needsOnboarding", createTenant: "createTenant" }, ngImport: i0, template: `
2682
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.17", type: TenantLoginComponent, isStandalone: true, selector: "lib-tenant-login", inputs: { title: "title", providers: "providers", showTenantSelector: "showTenantSelector", autoSelectSingleTenant: "autoSelectSingleTenant", prefillEmail: "prefillEmail", allowTenantCreation: "allowTenantCreation", tenantSelectorTitle: "tenantSelectorTitle", tenantSelectorDescription: "tenantSelectorDescription", continueButtonText: "continueButtonText", registerLinkText: "registerLinkText", registerLinkAction: "registerLinkAction", createTenantLinkText: "createTenantLinkText", createTenantLinkAction: "createTenantLinkAction" }, outputs: { tenantSelected: "tenantSelected", needsOnboarding: "needsOnboarding", createTenant: "createTenant" }, viewQueries: [{ propertyName: "otpInputs", predicate: ["otpInput"], descendants: true }], ngImport: i0, template: `
2269
2683
  <div class="tenant-login-dialog">
2270
2684
  @if (!showingTenantSelector) {
2271
2685
  <!-- Step 1: Authentication -->
2272
2686
  <h2 class="login-title">{{ title }}</h2>
2273
2687
 
2688
+ <!-- OTP Flow -->
2689
+ @if (isProviderEnabled('otp') && otpActive) {
2690
+ <!-- OTP Step 1: Identifier entry -->
2691
+ @if (otpStep === 'identifier') {
2692
+ <form (ngSubmit)="onOtpSend()" class="otp-form">
2693
+ <div class="form-group">
2694
+ <input
2695
+ [(ngModel)]="otpIdentifier"
2696
+ name="otpIdentifier"
2697
+ placeholder="Enter Email or Phone Number"
2698
+ type="text"
2699
+ required
2700
+ autocomplete="email tel"
2701
+ class="form-control"
2702
+ (input)="onOtpIdentifierChange()">
2703
+ @if (otpIdentifierHint) {
2704
+ <div class="field-hint">{{ otpIdentifierHint }}</div>
2705
+ }
2706
+ </div>
2707
+ <button
2708
+ type="submit"
2709
+ [disabled]="loading || !otpIdentifier.trim()"
2710
+ class="btn btn-primary btn-block">
2711
+ {{ loading ? 'Sending...' : 'Send OTP' }}
2712
+ </button>
2713
+ </form>
2714
+ }
2715
+
2716
+ <!-- OTP Step 2: Code entry -->
2717
+ @if (otpStep === 'code') {
2718
+ <div class="otp-code-section">
2719
+ <p class="otp-subtitle">
2720
+ Enter the 6-digit code sent to
2721
+ <strong>{{ otpMaskedIdentifier }}</strong>
2722
+ </p>
2723
+ <div class="otp-digits">
2724
+ @for (digit of otpDigits; track $index; let i = $index) {
2725
+ <input
2726
+ #otpInput
2727
+ type="text"
2728
+ inputmode="numeric"
2729
+ maxlength="1"
2730
+ class="otp-digit-input"
2731
+ [value]="otpDigits[i]"
2732
+ (input)="onOtpDigitInput($event, i)"
2733
+ (keydown)="onOtpDigitKeydown($event, i)"
2734
+ (paste)="onOtpPaste($event)">
2735
+ }
2736
+ </div>
2737
+ <div class="otp-actions">
2738
+ <button
2739
+ type="button"
2740
+ (click)="onOtpVerify()"
2741
+ [disabled]="loading || otpCode.length < 6"
2742
+ class="btn btn-primary btn-block">
2743
+ {{ loading ? 'Verifying...' : 'Verify' }}
2744
+ </button>
2745
+ </div>
2746
+ <div class="otp-resend">
2747
+ @if (otpResendCountdown > 0) {
2748
+ <span class="resend-timer">
2749
+ Resend in {{ formatCountdown(otpResendCountdown) }}
2750
+ </span>
2751
+ } @else {
2752
+ <a href="#" (click)="onOtpResend($event)" class="resend-link">
2753
+ Resend OTP
2754
+ </a>
2755
+ }
2756
+ </div>
2757
+ <div class="otp-back">
2758
+ <a href="#" (click)="onOtpBack($event)">
2759
+ Use a different email or phone
2760
+ </a>
2761
+ </div>
2762
+ </div>
2763
+ }
2764
+
2765
+ <!-- OTP Step 3: Registration (new user) -->
2766
+ @if (otpStep === 'register') {
2767
+ <div class="otp-register-section">
2768
+ <p class="otp-subtitle">
2769
+ Welcome! Enter your name to get started.
2770
+ </p>
2771
+ <form (ngSubmit)="onOtpRegister()" class="otp-form">
2772
+ <div class="form-group">
2773
+ <input
2774
+ [(ngModel)]="otpDisplayName"
2775
+ name="displayName"
2776
+ placeholder="Your Name"
2777
+ type="text"
2778
+ required
2779
+ class="form-control">
2780
+ </div>
2781
+ <button
2782
+ type="submit"
2783
+ [disabled]="loading || !otpDisplayName.trim()"
2784
+ class="btn btn-primary btn-block">
2785
+ {{ loading ? 'Creating account...' : 'Continue' }}
2786
+ </button>
2787
+ </form>
2788
+ </div>
2789
+ }
2790
+
2791
+ <!-- Divider before other providers -->
2792
+ @if (effectiveOauthProviders.length > 0 || isProviderEnabled('emailPassword')) {
2793
+ <div class="divider">
2794
+ <span>OR</span>
2795
+ </div>
2796
+ }
2797
+ }
2798
+
2274
2799
  <!-- Email/Password Form (if enabled) -->
2275
- @if (isProviderEnabled('emailPassword') && !useOAuth) {
2800
+ @if (isProviderEnabled('emailPassword') && !useOAuth && !otpActive) {
2276
2801
  <form (ngSubmit)="onEmailLogin()" class="email-form">
2277
2802
  <div class="form-group">
2278
2803
  <input
@@ -2296,7 +2821,7 @@ class TenantLoginComponent {
2296
2821
  class="password-toggle"
2297
2822
  (click)="showPassword = !showPassword"
2298
2823
  [attr.aria-label]="showPassword ? 'Hide password' : 'Show password'">
2299
- {{ showPassword ? '👁️' : '👁️‍🗨️' }}
2824
+ {{ showPassword ? '&#x1F441;' : '&#x1F441;&#x200D;&#x1F5E8;' }}
2300
2825
  </button>
2301
2826
  </div>
2302
2827
  <button
@@ -2308,7 +2833,7 @@ class TenantLoginComponent {
2308
2833
  </form>
2309
2834
 
2310
2835
  <!-- Divider -->
2311
- @if (oauthProviders.length > 0) {
2836
+ @if (effectiveOauthProviders.length > 0) {
2312
2837
  <div class="divider">
2313
2838
  <span>OR</span>
2314
2839
  </div>
@@ -2316,9 +2841,9 @@ class TenantLoginComponent {
2316
2841
  }
2317
2842
 
2318
2843
  <!-- OAuth Providers -->
2319
- @if (oauthProviders.length > 0 && (useOAuth || !isProviderEnabled('emailPassword'))) {
2844
+ @if (effectiveOauthProviders.length > 0 && !otpActive && (useOAuth || !isProviderEnabled('emailPassword'))) {
2320
2845
  <div class="oauth-buttons">
2321
- @for (provider of oauthProviders; track provider) {
2846
+ @for (provider of effectiveOauthProviders; track provider) {
2322
2847
  <button
2323
2848
  type="button"
2324
2849
  (click)="onOAuthLogin(provider)"
@@ -2336,7 +2861,7 @@ class TenantLoginComponent {
2336
2861
  </div>
2337
2862
 
2338
2863
  <!-- Switch to Email/Password -->
2339
- @if (isProviderEnabled('emailPassword') && oauthProviders.length > 0) {
2864
+ @if (isProviderEnabled('emailPassword') && effectiveOauthProviders.length > 0) {
2340
2865
  <div class="switch-method">
2341
2866
  <a href="#" (click)="toggleAuthMethod($event)">
2342
2867
  {{ useOAuth ? 'Use email/password instead' : 'Use OAuth instead' }}
@@ -2431,7 +2956,7 @@ class TenantLoginComponent {
2431
2956
  </div>
2432
2957
  }
2433
2958
  </div>
2434
- `, isInline: true, styles: [".tenant-login-dialog{padding:24px;max-width:450px;position:relative}.login-title{margin:0 0 24px;font-size:24px;font-weight:500;text-align:center}.welcome-message{margin-bottom:16px;padding:12px;background:#e8f5e9;border-radius:4px;text-align:center;font-size:14px;color:#2e7d32}.selector-description{margin-bottom:20px;font-size:14px;color:#666;text-align:center}.email-form,.form-group{margin-bottom:16px}.password-group{position:relative}.form-control{width:100%;padding:12px;border:1px solid #ddd;border-radius:4px;font-size:14px;box-sizing:border-box}.password-input{padding-right:45px}.password-toggle{position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;font-size:18px;padding:8px;line-height:1;opacity:.6;transition:opacity .2s}.password-toggle:hover{opacity:1}.password-toggle:focus{outline:2px solid #4285f4;outline-offset:2px;border-radius:4px}.form-control:focus{outline:none;border-color:#4285f4}.btn{padding:12px 24px;border:none;border-radius:4px;font-size:14px;font-weight:500;cursor:pointer;transition:background-color .2s}.btn:disabled{opacity:.6;cursor:not-allowed}.btn-block{width:100%}.btn-primary{background-color:#4285f4;color:#fff}.btn-primary:hover:not(:disabled){background-color:#357ae8}.divider{margin:16px 0;text-align:center;position:relative}.divider:before{content:\"\";position:absolute;top:50%;left:0;right:0;height:1px;background:#ddd}.divider span{background:#fff;padding:0 12px;position:relative;color:#666;font-size:12px}.oauth-buttons{display:flex;flex-direction:column;gap:12px}.btn-oauth{width:100%;background:#fff;color:#333;border:1px solid #ddd;display:flex;align-items:center;justify-content:center;gap:8px}.btn-oauth:hover:not(:disabled){background:#f8f8f8}.btn-google{border-color:#4285f4}.btn-linkedin{border-color:#0077b5}.btn-apple{border-color:#000}.btn-microsoft{border-color:#00a4ef}.btn-github{border-color:#333}.btn-zoho{background-color:#f0483e;color:#fff;border:1px solid #d63b32}.btn-zoho:hover{background-color:#d63b32}.oauth-icon{font-size:18px}.switch-method{margin-top:12px;text-align:center;font-size:14px}.switch-method a{color:#4285f4;text-decoration:none}.switch-method a:hover{text-decoration:underline}.tenant-list{margin-bottom:20px;display:flex;flex-direction:column;gap:12px}.tenant-item{display:flex;align-items:flex-start;gap:12px;padding:16px;border:2px solid #e0e0e0;border-radius:6px;cursor:pointer;transition:all .2s}.tenant-item:hover{border-color:#4285f4;background:#f8f9ff}.tenant-item.selected{border-color:#4285f4;background:#e8f0fe}.tenant-radio{flex-shrink:0;padding-top:2px}.tenant-radio input[type=radio]{width:18px;height:18px;cursor:pointer}.tenant-info{flex:1}.tenant-name{font-size:16px;font-weight:500;color:#333;margin-bottom:4px}.tenant-meta{font-size:13px;color:#666}.tenant-role{font-weight:500;color:#4285f4}.tenant-separator{margin:0 6px}.error-message{margin-top:16px;padding:12px;background:#fee;color:#c33;border-radius:4px;font-size:14px}.loading-overlay{position:absolute;inset:0;background:#fffc;display:flex;align-items:center;justify-content:center}.spinner{width:40px;height:40px;border:4px solid #f3f3f3;border-top:4px solid #4285f4;border-radius:50%;animation:spin 1s linear infinite}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.register-link{margin-top:16px;text-align:center;font-size:14px;color:#666}.register-link a{color:#4285f4;text-decoration:none}.register-link a:hover{text-decoration:underline}.create-tenant-link{margin-top:16px;padding-top:16px;border-top:1px solid #e0e0e0;text-align:center;font-size:14px;color:#666}.create-tenant-link a{color:#4285f4;text-decoration:none;font-weight:500}.create-tenant-link a:hover{text-decoration:underline}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i3.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i4.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i4.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i4.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i4.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i4.RequiredValidator, selector: ":not([type=checkbox])[required][formControlName],:not([type=checkbox])[required][formControl],:not([type=checkbox])[required][ngModel]", inputs: ["required"] }, { kind: "directive", type: i4.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i4.NgForm, selector: "form:not([ngNoForm]):not([formGroup]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }] });
2959
+ `, isInline: true, styles: [".tenant-login-dialog{padding:24px;max-width:450px;position:relative}.login-title{margin:0 0 24px;font-size:24px;font-weight:500;text-align:center}.welcome-message{margin-bottom:16px;padding:12px;background:#e8f5e9;border-radius:4px;text-align:center;font-size:14px;color:#2e7d32}.selector-description{margin-bottom:20px;font-size:14px;color:#666;text-align:center}.email-form,.otp-form,.form-group{margin-bottom:16px}.password-group{position:relative}.form-control{width:100%;padding:12px;border:1px solid #ddd;border-radius:4px;font-size:14px;box-sizing:border-box}.password-input{padding-right:45px}.password-toggle{position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;font-size:18px;padding:8px;line-height:1;opacity:.6;transition:opacity .2s}.password-toggle:hover{opacity:1}.password-toggle:focus{outline:2px solid #4285f4;outline-offset:2px;border-radius:4px}.form-control:focus{outline:none;border-color:#4285f4}.field-hint{margin-top:4px;font-size:12px;color:#888}.btn{padding:12px 24px;border:none;border-radius:4px;font-size:14px;font-weight:500;cursor:pointer;transition:background-color .2s}.btn:disabled{opacity:.6;cursor:not-allowed}.btn-block{width:100%}.btn-primary{background-color:#4285f4;color:#fff}.btn-primary:hover:not(:disabled){background-color:#357ae8}.divider{margin:16px 0;text-align:center;position:relative}.divider:before{content:\"\";position:absolute;top:50%;left:0;right:0;height:1px;background:#ddd}.divider span{background:#fff;padding:0 12px;position:relative;color:#666;font-size:12px}.oauth-buttons{display:flex;flex-direction:column;gap:12px}.btn-oauth{width:100%;background:#fff;color:#333;border:1px solid #ddd;display:flex;align-items:center;justify-content:center;gap:8px}.btn-oauth:hover:not(:disabled){background:#f8f8f8}.btn-google{border-color:#4285f4}.btn-linkedin{border-color:#0077b5}.btn-apple{border-color:#000}.btn-microsoft{border-color:#00a4ef}.btn-github{border-color:#333}.btn-zoho{background-color:#f0483e;color:#fff;border:1px solid #d63b32}.btn-zoho:hover{background-color:#d63b32}.oauth-icon{font-size:18px}.switch-method{margin-top:12px;text-align:center;font-size:14px}.switch-method a{color:#4285f4;text-decoration:none}.switch-method a:hover{text-decoration:underline}.otp-subtitle{text-align:center;font-size:14px;color:#555;margin-bottom:20px}.otp-digits{display:flex;justify-content:center;gap:8px;margin-bottom:20px}.otp-digit-input{width:44px;height:52px;text-align:center;font-size:22px;font-weight:600;border:2px solid #ddd;border-radius:8px;outline:none;transition:border-color .2s;box-sizing:border-box}.otp-digit-input:focus{border-color:#4285f4}.otp-actions{margin-bottom:12px}.otp-resend{text-align:center;font-size:14px;margin-bottom:8px}.resend-timer{color:#888}.resend-link{color:#4285f4;text-decoration:none;cursor:pointer}.resend-link:hover{text-decoration:underline}.otp-back{text-align:center;font-size:13px}.otp-back a{color:#888;text-decoration:none}.otp-back a:hover{text-decoration:underline;color:#4285f4}.otp-register-section .otp-subtitle{color:#2e7d32}.tenant-list{margin-bottom:20px;display:flex;flex-direction:column;gap:12px}.tenant-item{display:flex;align-items:flex-start;gap:12px;padding:16px;border:2px solid #e0e0e0;border-radius:6px;cursor:pointer;transition:all .2s}.tenant-item:hover{border-color:#4285f4;background:#f8f9ff}.tenant-item.selected{border-color:#4285f4;background:#e8f0fe}.tenant-radio{flex-shrink:0;padding-top:2px}.tenant-radio input[type=radio]{width:18px;height:18px;cursor:pointer}.tenant-info{flex:1}.tenant-name{font-size:16px;font-weight:500;color:#333;margin-bottom:4px}.tenant-meta{font-size:13px;color:#666}.tenant-role{font-weight:500;color:#4285f4}.tenant-separator{margin:0 6px}.error-message{margin-top:16px;padding:12px;background:#fee;color:#c33;border-radius:4px;font-size:14px}.loading-overlay{position:absolute;inset:0;background:#fffc;display:flex;align-items:center;justify-content:center}.spinner{width:40px;height:40px;border:4px solid #f3f3f3;border-top:4px solid #4285f4;border-radius:50%;animation:spin 1s linear infinite}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.register-link{margin-top:16px;text-align:center;font-size:14px;color:#666}.register-link a{color:#4285f4;text-decoration:none}.register-link a:hover{text-decoration:underline}.create-tenant-link{margin-top:16px;padding-top:16px;border-top:1px solid #e0e0e0;text-align:center;font-size:14px;color:#666}.create-tenant-link a{color:#4285f4;text-decoration:none;font-weight:500}.create-tenant-link a:hover{text-decoration:underline}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i3.NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i4.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i4.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i4.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i4.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i4.RequiredValidator, selector: ":not([type=checkbox])[required][formControlName],:not([type=checkbox])[required][formControl],:not([type=checkbox])[required][ngModel]", inputs: ["required"] }, { kind: "directive", type: i4.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i4.NgForm, selector: "form:not([ngNoForm]):not([formGroup]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }] });
2435
2960
  }
2436
2961
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: TenantLoginComponent, decorators: [{
2437
2962
  type: Component,
@@ -2441,8 +2966,119 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
2441
2966
  <!-- Step 1: Authentication -->
2442
2967
  <h2 class="login-title">{{ title }}</h2>
2443
2968
 
2969
+ <!-- OTP Flow -->
2970
+ @if (isProviderEnabled('otp') && otpActive) {
2971
+ <!-- OTP Step 1: Identifier entry -->
2972
+ @if (otpStep === 'identifier') {
2973
+ <form (ngSubmit)="onOtpSend()" class="otp-form">
2974
+ <div class="form-group">
2975
+ <input
2976
+ [(ngModel)]="otpIdentifier"
2977
+ name="otpIdentifier"
2978
+ placeholder="Enter Email or Phone Number"
2979
+ type="text"
2980
+ required
2981
+ autocomplete="email tel"
2982
+ class="form-control"
2983
+ (input)="onOtpIdentifierChange()">
2984
+ @if (otpIdentifierHint) {
2985
+ <div class="field-hint">{{ otpIdentifierHint }}</div>
2986
+ }
2987
+ </div>
2988
+ <button
2989
+ type="submit"
2990
+ [disabled]="loading || !otpIdentifier.trim()"
2991
+ class="btn btn-primary btn-block">
2992
+ {{ loading ? 'Sending...' : 'Send OTP' }}
2993
+ </button>
2994
+ </form>
2995
+ }
2996
+
2997
+ <!-- OTP Step 2: Code entry -->
2998
+ @if (otpStep === 'code') {
2999
+ <div class="otp-code-section">
3000
+ <p class="otp-subtitle">
3001
+ Enter the 6-digit code sent to
3002
+ <strong>{{ otpMaskedIdentifier }}</strong>
3003
+ </p>
3004
+ <div class="otp-digits">
3005
+ @for (digit of otpDigits; track $index; let i = $index) {
3006
+ <input
3007
+ #otpInput
3008
+ type="text"
3009
+ inputmode="numeric"
3010
+ maxlength="1"
3011
+ class="otp-digit-input"
3012
+ [value]="otpDigits[i]"
3013
+ (input)="onOtpDigitInput($event, i)"
3014
+ (keydown)="onOtpDigitKeydown($event, i)"
3015
+ (paste)="onOtpPaste($event)">
3016
+ }
3017
+ </div>
3018
+ <div class="otp-actions">
3019
+ <button
3020
+ type="button"
3021
+ (click)="onOtpVerify()"
3022
+ [disabled]="loading || otpCode.length < 6"
3023
+ class="btn btn-primary btn-block">
3024
+ {{ loading ? 'Verifying...' : 'Verify' }}
3025
+ </button>
3026
+ </div>
3027
+ <div class="otp-resend">
3028
+ @if (otpResendCountdown > 0) {
3029
+ <span class="resend-timer">
3030
+ Resend in {{ formatCountdown(otpResendCountdown) }}
3031
+ </span>
3032
+ } @else {
3033
+ <a href="#" (click)="onOtpResend($event)" class="resend-link">
3034
+ Resend OTP
3035
+ </a>
3036
+ }
3037
+ </div>
3038
+ <div class="otp-back">
3039
+ <a href="#" (click)="onOtpBack($event)">
3040
+ Use a different email or phone
3041
+ </a>
3042
+ </div>
3043
+ </div>
3044
+ }
3045
+
3046
+ <!-- OTP Step 3: Registration (new user) -->
3047
+ @if (otpStep === 'register') {
3048
+ <div class="otp-register-section">
3049
+ <p class="otp-subtitle">
3050
+ Welcome! Enter your name to get started.
3051
+ </p>
3052
+ <form (ngSubmit)="onOtpRegister()" class="otp-form">
3053
+ <div class="form-group">
3054
+ <input
3055
+ [(ngModel)]="otpDisplayName"
3056
+ name="displayName"
3057
+ placeholder="Your Name"
3058
+ type="text"
3059
+ required
3060
+ class="form-control">
3061
+ </div>
3062
+ <button
3063
+ type="submit"
3064
+ [disabled]="loading || !otpDisplayName.trim()"
3065
+ class="btn btn-primary btn-block">
3066
+ {{ loading ? 'Creating account...' : 'Continue' }}
3067
+ </button>
3068
+ </form>
3069
+ </div>
3070
+ }
3071
+
3072
+ <!-- Divider before other providers -->
3073
+ @if (effectiveOauthProviders.length > 0 || isProviderEnabled('emailPassword')) {
3074
+ <div class="divider">
3075
+ <span>OR</span>
3076
+ </div>
3077
+ }
3078
+ }
3079
+
2444
3080
  <!-- Email/Password Form (if enabled) -->
2445
- @if (isProviderEnabled('emailPassword') && !useOAuth) {
3081
+ @if (isProviderEnabled('emailPassword') && !useOAuth && !otpActive) {
2446
3082
  <form (ngSubmit)="onEmailLogin()" class="email-form">
2447
3083
  <div class="form-group">
2448
3084
  <input
@@ -2466,7 +3102,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
2466
3102
  class="password-toggle"
2467
3103
  (click)="showPassword = !showPassword"
2468
3104
  [attr.aria-label]="showPassword ? 'Hide password' : 'Show password'">
2469
- {{ showPassword ? '👁️' : '👁️‍🗨️' }}
3105
+ {{ showPassword ? '&#x1F441;' : '&#x1F441;&#x200D;&#x1F5E8;' }}
2470
3106
  </button>
2471
3107
  </div>
2472
3108
  <button
@@ -2478,7 +3114,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
2478
3114
  </form>
2479
3115
 
2480
3116
  <!-- Divider -->
2481
- @if (oauthProviders.length > 0) {
3117
+ @if (effectiveOauthProviders.length > 0) {
2482
3118
  <div class="divider">
2483
3119
  <span>OR</span>
2484
3120
  </div>
@@ -2486,9 +3122,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
2486
3122
  }
2487
3123
 
2488
3124
  <!-- OAuth Providers -->
2489
- @if (oauthProviders.length > 0 && (useOAuth || !isProviderEnabled('emailPassword'))) {
3125
+ @if (effectiveOauthProviders.length > 0 && !otpActive && (useOAuth || !isProviderEnabled('emailPassword'))) {
2490
3126
  <div class="oauth-buttons">
2491
- @for (provider of oauthProviders; track provider) {
3127
+ @for (provider of effectiveOauthProviders; track provider) {
2492
3128
  <button
2493
3129
  type="button"
2494
3130
  (click)="onOAuthLogin(provider)"
@@ -2506,7 +3142,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
2506
3142
  </div>
2507
3143
 
2508
3144
  <!-- Switch to Email/Password -->
2509
- @if (isProviderEnabled('emailPassword') && oauthProviders.length > 0) {
3145
+ @if (isProviderEnabled('emailPassword') && effectiveOauthProviders.length > 0) {
2510
3146
  <div class="switch-method">
2511
3147
  <a href="#" (click)="toggleAuthMethod($event)">
2512
3148
  {{ useOAuth ? 'Use email/password instead' : 'Use OAuth instead' }}
@@ -2601,8 +3237,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
2601
3237
  </div>
2602
3238
  }
2603
3239
  </div>
2604
- `, styles: [".tenant-login-dialog{padding:24px;max-width:450px;position:relative}.login-title{margin:0 0 24px;font-size:24px;font-weight:500;text-align:center}.welcome-message{margin-bottom:16px;padding:12px;background:#e8f5e9;border-radius:4px;text-align:center;font-size:14px;color:#2e7d32}.selector-description{margin-bottom:20px;font-size:14px;color:#666;text-align:center}.email-form,.form-group{margin-bottom:16px}.password-group{position:relative}.form-control{width:100%;padding:12px;border:1px solid #ddd;border-radius:4px;font-size:14px;box-sizing:border-box}.password-input{padding-right:45px}.password-toggle{position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;font-size:18px;padding:8px;line-height:1;opacity:.6;transition:opacity .2s}.password-toggle:hover{opacity:1}.password-toggle:focus{outline:2px solid #4285f4;outline-offset:2px;border-radius:4px}.form-control:focus{outline:none;border-color:#4285f4}.btn{padding:12px 24px;border:none;border-radius:4px;font-size:14px;font-weight:500;cursor:pointer;transition:background-color .2s}.btn:disabled{opacity:.6;cursor:not-allowed}.btn-block{width:100%}.btn-primary{background-color:#4285f4;color:#fff}.btn-primary:hover:not(:disabled){background-color:#357ae8}.divider{margin:16px 0;text-align:center;position:relative}.divider:before{content:\"\";position:absolute;top:50%;left:0;right:0;height:1px;background:#ddd}.divider span{background:#fff;padding:0 12px;position:relative;color:#666;font-size:12px}.oauth-buttons{display:flex;flex-direction:column;gap:12px}.btn-oauth{width:100%;background:#fff;color:#333;border:1px solid #ddd;display:flex;align-items:center;justify-content:center;gap:8px}.btn-oauth:hover:not(:disabled){background:#f8f8f8}.btn-google{border-color:#4285f4}.btn-linkedin{border-color:#0077b5}.btn-apple{border-color:#000}.btn-microsoft{border-color:#00a4ef}.btn-github{border-color:#333}.btn-zoho{background-color:#f0483e;color:#fff;border:1px solid #d63b32}.btn-zoho:hover{background-color:#d63b32}.oauth-icon{font-size:18px}.switch-method{margin-top:12px;text-align:center;font-size:14px}.switch-method a{color:#4285f4;text-decoration:none}.switch-method a:hover{text-decoration:underline}.tenant-list{margin-bottom:20px;display:flex;flex-direction:column;gap:12px}.tenant-item{display:flex;align-items:flex-start;gap:12px;padding:16px;border:2px solid #e0e0e0;border-radius:6px;cursor:pointer;transition:all .2s}.tenant-item:hover{border-color:#4285f4;background:#f8f9ff}.tenant-item.selected{border-color:#4285f4;background:#e8f0fe}.tenant-radio{flex-shrink:0;padding-top:2px}.tenant-radio input[type=radio]{width:18px;height:18px;cursor:pointer}.tenant-info{flex:1}.tenant-name{font-size:16px;font-weight:500;color:#333;margin-bottom:4px}.tenant-meta{font-size:13px;color:#666}.tenant-role{font-weight:500;color:#4285f4}.tenant-separator{margin:0 6px}.error-message{margin-top:16px;padding:12px;background:#fee;color:#c33;border-radius:4px;font-size:14px}.loading-overlay{position:absolute;inset:0;background:#fffc;display:flex;align-items:center;justify-content:center}.spinner{width:40px;height:40px;border:4px solid #f3f3f3;border-top:4px solid #4285f4;border-radius:50%;animation:spin 1s linear infinite}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.register-link{margin-top:16px;text-align:center;font-size:14px;color:#666}.register-link a{color:#4285f4;text-decoration:none}.register-link a:hover{text-decoration:underline}.create-tenant-link{margin-top:16px;padding-top:16px;border-top:1px solid #e0e0e0;text-align:center;font-size:14px;color:#666}.create-tenant-link a{color:#4285f4;text-decoration:none;font-weight:500}.create-tenant-link a:hover{text-decoration:underline}\n"] }]
2605
- }], ctorParameters: () => [{ type: AuthService }, { type: ProviderRegistryService }], propDecorators: { title: [{
3240
+ `, styles: [".tenant-login-dialog{padding:24px;max-width:450px;position:relative}.login-title{margin:0 0 24px;font-size:24px;font-weight:500;text-align:center}.welcome-message{margin-bottom:16px;padding:12px;background:#e8f5e9;border-radius:4px;text-align:center;font-size:14px;color:#2e7d32}.selector-description{margin-bottom:20px;font-size:14px;color:#666;text-align:center}.email-form,.otp-form,.form-group{margin-bottom:16px}.password-group{position:relative}.form-control{width:100%;padding:12px;border:1px solid #ddd;border-radius:4px;font-size:14px;box-sizing:border-box}.password-input{padding-right:45px}.password-toggle{position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;font-size:18px;padding:8px;line-height:1;opacity:.6;transition:opacity .2s}.password-toggle:hover{opacity:1}.password-toggle:focus{outline:2px solid #4285f4;outline-offset:2px;border-radius:4px}.form-control:focus{outline:none;border-color:#4285f4}.field-hint{margin-top:4px;font-size:12px;color:#888}.btn{padding:12px 24px;border:none;border-radius:4px;font-size:14px;font-weight:500;cursor:pointer;transition:background-color .2s}.btn:disabled{opacity:.6;cursor:not-allowed}.btn-block{width:100%}.btn-primary{background-color:#4285f4;color:#fff}.btn-primary:hover:not(:disabled){background-color:#357ae8}.divider{margin:16px 0;text-align:center;position:relative}.divider:before{content:\"\";position:absolute;top:50%;left:0;right:0;height:1px;background:#ddd}.divider span{background:#fff;padding:0 12px;position:relative;color:#666;font-size:12px}.oauth-buttons{display:flex;flex-direction:column;gap:12px}.btn-oauth{width:100%;background:#fff;color:#333;border:1px solid #ddd;display:flex;align-items:center;justify-content:center;gap:8px}.btn-oauth:hover:not(:disabled){background:#f8f8f8}.btn-google{border-color:#4285f4}.btn-linkedin{border-color:#0077b5}.btn-apple{border-color:#000}.btn-microsoft{border-color:#00a4ef}.btn-github{border-color:#333}.btn-zoho{background-color:#f0483e;color:#fff;border:1px solid #d63b32}.btn-zoho:hover{background-color:#d63b32}.oauth-icon{font-size:18px}.switch-method{margin-top:12px;text-align:center;font-size:14px}.switch-method a{color:#4285f4;text-decoration:none}.switch-method a:hover{text-decoration:underline}.otp-subtitle{text-align:center;font-size:14px;color:#555;margin-bottom:20px}.otp-digits{display:flex;justify-content:center;gap:8px;margin-bottom:20px}.otp-digit-input{width:44px;height:52px;text-align:center;font-size:22px;font-weight:600;border:2px solid #ddd;border-radius:8px;outline:none;transition:border-color .2s;box-sizing:border-box}.otp-digit-input:focus{border-color:#4285f4}.otp-actions{margin-bottom:12px}.otp-resend{text-align:center;font-size:14px;margin-bottom:8px}.resend-timer{color:#888}.resend-link{color:#4285f4;text-decoration:none;cursor:pointer}.resend-link:hover{text-decoration:underline}.otp-back{text-align:center;font-size:13px}.otp-back a{color:#888;text-decoration:none}.otp-back a:hover{text-decoration:underline;color:#4285f4}.otp-register-section .otp-subtitle{color:#2e7d32}.tenant-list{margin-bottom:20px;display:flex;flex-direction:column;gap:12px}.tenant-item{display:flex;align-items:flex-start;gap:12px;padding:16px;border:2px solid #e0e0e0;border-radius:6px;cursor:pointer;transition:all .2s}.tenant-item:hover{border-color:#4285f4;background:#f8f9ff}.tenant-item.selected{border-color:#4285f4;background:#e8f0fe}.tenant-radio{flex-shrink:0;padding-top:2px}.tenant-radio input[type=radio]{width:18px;height:18px;cursor:pointer}.tenant-info{flex:1}.tenant-name{font-size:16px;font-weight:500;color:#333;margin-bottom:4px}.tenant-meta{font-size:13px;color:#666}.tenant-role{font-weight:500;color:#4285f4}.tenant-separator{margin:0 6px}.error-message{margin-top:16px;padding:12px;background:#fee;color:#c33;border-radius:4px;font-size:14px}.loading-overlay{position:absolute;inset:0;background:#fffc;display:flex;align-items:center;justify-content:center}.spinner{width:40px;height:40px;border:4px solid #f3f3f3;border-top:4px solid #4285f4;border-radius:50%;animation:spin 1s linear infinite}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.register-link{margin-top:16px;text-align:center;font-size:14px;color:#666}.register-link a{color:#4285f4;text-decoration:none}.register-link a:hover{text-decoration:underline}.create-tenant-link{margin-top:16px;padding-top:16px;border-top:1px solid #e0e0e0;text-align:center;font-size:14px;color:#666}.create-tenant-link a{color:#4285f4;text-decoration:none;font-weight:500}.create-tenant-link a:hover{text-decoration:underline}\n"] }]
3241
+ }], ctorParameters: () => [{ type: AuthService }, { type: ProviderRegistryService }], propDecorators: { otpInputs: [{
3242
+ type: ViewChildren,
3243
+ args: ['otpInput']
3244
+ }], title: [{
2606
3245
  type: Input
2607
3246
  }], providers: [{
2608
3247
  type: Input