@progalaxyelabs/ngx-stonescriptphp-client 1.22.0 → 1.23.0

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,244 @@ 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}` : (raw.startsWith('+') ? raw : `+${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
+ const code = this.otpCode;
2476
+ if (code.length < 6) {
2477
+ this.error = 'Please enter the complete 6-digit code';
2478
+ return;
2479
+ }
2480
+ this.loading = true;
2481
+ this.error = '';
2482
+ try {
2483
+ const verifyResult = await this.auth.verifyOtp(this.otpNormalizedIdentifier, code);
2484
+ if (!verifyResult.success) {
2485
+ this.error = 'Invalid code. Please try again.';
2486
+ return;
2487
+ }
2488
+ this.otpVerifiedToken = verifyResult.verified_token;
2489
+ // Auto-attempt login
2490
+ const loginResult = await this.auth.identityLogin(this.otpVerifiedToken);
2491
+ if (loginResult.success) {
2492
+ // Existing user — proceed with standard post-auth flow
2493
+ await this.handlePostAuthFlow(loginResult);
2494
+ return;
2495
+ }
2496
+ if (loginResult.message === 'identity_not_found') {
2497
+ // New user — show registration form
2498
+ this.otpStep = 'register';
2499
+ return;
2500
+ }
2501
+ // Other login error
2502
+ this.error = loginResult.message || 'Login failed. Please try again.';
2503
+ }
2504
+ catch (err) {
2505
+ this.error = err.message || 'Verification failed';
2506
+ }
2507
+ finally {
2508
+ this.loading = false;
2509
+ }
2510
+ }
2511
+ /** Register a new identity after OTP verification */
2512
+ async onOtpRegister() {
2513
+ const name = this.otpDisplayName.trim();
2514
+ if (!name) {
2515
+ this.error = 'Please enter your name';
2516
+ return;
2517
+ }
2518
+ this.loading = true;
2519
+ this.error = '';
2520
+ try {
2521
+ const result = await this.auth.identityRegister(this.otpVerifiedToken, name);
2522
+ if (!result.success) {
2523
+ this.error = result.message || 'Registration failed';
2524
+ return;
2525
+ }
2526
+ // New identity created — emit onboarding event
2527
+ const identifierType = this.detectIdentifierType(this.otpIdentifier.trim());
2528
+ this.needsOnboarding.emit({
2529
+ auth_method: 'otp',
2530
+ is_new_identity: true,
2531
+ identity: {
2532
+ email: identifierType === 'email' ? this.otpNormalizedIdentifier : '',
2533
+ phone: identifierType === 'phone' ? this.otpNormalizedIdentifier : undefined,
2534
+ display_name: name,
2535
+ },
2536
+ });
2537
+ }
2538
+ catch (err) {
2539
+ this.error = err.message || 'Registration failed';
2540
+ }
2541
+ finally {
2542
+ this.loading = false;
2543
+ }
2544
+ }
2545
+ /** Resend the OTP */
2546
+ onOtpResend(event) {
2547
+ event.preventDefault();
2548
+ this.onOtpSend();
2549
+ }
2550
+ /** Go back to identifier entry */
2551
+ onOtpBack(event) {
2552
+ event.preventDefault();
2553
+ this.otpStep = 'identifier';
2554
+ this.otpDigits = ['', '', '', '', '', ''];
2555
+ this.otpVerifiedToken = '';
2556
+ this.error = '';
2557
+ this.clearResendTimer();
2558
+ }
2559
+ // ── OTP digit input handling ─────────────────────────────────────────────
2560
+ onOtpDigitInput(event, index) {
2561
+ const input = event.target;
2562
+ const value = input.value.replace(/\D/g, '');
2563
+ this.otpDigits[index] = value ? value[0] : '';
2564
+ input.value = this.otpDigits[index];
2565
+ // Auto-advance to next input
2566
+ if (value && index < 5) {
2567
+ this.focusOtpInput(index + 1);
2568
+ }
2569
+ // Auto-verify when all 6 digits entered
2570
+ if (this.otpCode.length === 6) {
2571
+ this.onOtpVerify();
2572
+ }
2573
+ }
2574
+ onOtpDigitKeydown(event, index) {
2575
+ if (event.key === 'Backspace' && !this.otpDigits[index] && index > 0) {
2576
+ this.focusOtpInput(index - 1);
2577
+ }
2578
+ }
2579
+ onOtpPaste(event) {
2580
+ event.preventDefault();
2581
+ const pasted = (event.clipboardData?.getData('text') || '').replace(/\D/g, '').slice(0, 6);
2582
+ for (let i = 0; i < 6; i++) {
2583
+ this.otpDigits[i] = pasted[i] || '';
2584
+ }
2585
+ // Update all input elements
2586
+ const inputs = this.otpInputs?.toArray();
2587
+ if (inputs) {
2588
+ for (let i = 0; i < 6; i++) {
2589
+ inputs[i].nativeElement.value = this.otpDigits[i];
2590
+ }
2591
+ }
2592
+ if (pasted.length >= 6) {
2593
+ this.onOtpVerify();
2594
+ }
2595
+ else {
2596
+ this.focusOtpInput(Math.min(pasted.length, 5));
2597
+ }
2598
+ }
2599
+ get otpCode() {
2600
+ return this.otpDigits.join('');
2601
+ }
2602
+ // ── OTP helpers ──────────────────────────────────────────────────────────
2603
+ detectIdentifierType(value) {
2604
+ if (value.includes('@')) {
2605
+ // Basic email validation
2606
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? 'email' : null;
2607
+ }
2608
+ const digits = value.replace(/\D/g, '');
2609
+ if (digits.length >= 10 && digits.length <= 15) {
2610
+ return 'phone';
2611
+ }
2612
+ return null;
2613
+ }
2614
+ focusOtpInput(index) {
2615
+ const inputs = this.otpInputs?.toArray();
2616
+ if (inputs && inputs[index]) {
2617
+ inputs[index].nativeElement.focus();
2618
+ inputs[index].nativeElement.select();
2619
+ }
2620
+ }
2621
+ startResendCountdown(seconds) {
2622
+ this.clearResendTimer();
2623
+ this.otpResendCountdown = seconds;
2624
+ this.otpResendTimer = setInterval(() => {
2625
+ this.otpResendCountdown--;
2626
+ if (this.otpResendCountdown <= 0) {
2627
+ this.clearResendTimer();
2628
+ }
2629
+ }, 1000);
2630
+ }
2631
+ clearResendTimer() {
2632
+ if (this.otpResendTimer) {
2633
+ clearInterval(this.otpResendTimer);
2634
+ this.otpResendTimer = null;
2635
+ }
2636
+ this.otpResendCountdown = 0;
2637
+ }
2638
+ formatCountdown(seconds) {
2639
+ const m = Math.floor(seconds / 60);
2640
+ const s = seconds % 60;
2641
+ return `${m}:${s.toString().padStart(2, '0')}`;
2642
+ }
2643
+ // ── Formatting helpers ───────────────────────────────────────────────────
2240
2644
  formatRole(role) {
2241
2645
  return role.charAt(0).toUpperCase() + role.slice(1);
2242
2646
  }
@@ -2265,14 +2669,125 @@ class TenantLoginComponent {
2265
2669
  this.createTenant.emit();
2266
2670
  }
2267
2671
  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: `
2672
+ 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
2673
  <div class="tenant-login-dialog">
2270
2674
  @if (!showingTenantSelector) {
2271
2675
  <!-- Step 1: Authentication -->
2272
2676
  <h2 class="login-title">{{ title }}</h2>
2273
2677
 
2678
+ <!-- OTP Flow -->
2679
+ @if (isProviderEnabled('otp') && otpActive) {
2680
+ <!-- OTP Step 1: Identifier entry -->
2681
+ @if (otpStep === 'identifier') {
2682
+ <form (ngSubmit)="onOtpSend()" class="otp-form">
2683
+ <div class="form-group">
2684
+ <input
2685
+ [(ngModel)]="otpIdentifier"
2686
+ name="otpIdentifier"
2687
+ placeholder="Enter Email or Phone Number"
2688
+ type="text"
2689
+ required
2690
+ autocomplete="email tel"
2691
+ class="form-control"
2692
+ (input)="onOtpIdentifierChange()">
2693
+ @if (otpIdentifierHint) {
2694
+ <div class="field-hint">{{ otpIdentifierHint }}</div>
2695
+ }
2696
+ </div>
2697
+ <button
2698
+ type="submit"
2699
+ [disabled]="loading || !otpIdentifier.trim()"
2700
+ class="btn btn-primary btn-block">
2701
+ {{ loading ? 'Sending...' : 'Send OTP' }}
2702
+ </button>
2703
+ </form>
2704
+ }
2705
+
2706
+ <!-- OTP Step 2: Code entry -->
2707
+ @if (otpStep === 'code') {
2708
+ <div class="otp-code-section">
2709
+ <p class="otp-subtitle">
2710
+ Enter the 6-digit code sent to
2711
+ <strong>{{ otpMaskedIdentifier }}</strong>
2712
+ </p>
2713
+ <div class="otp-digits">
2714
+ @for (digit of otpDigits; track $index; let i = $index) {
2715
+ <input
2716
+ #otpInput
2717
+ type="text"
2718
+ inputmode="numeric"
2719
+ maxlength="1"
2720
+ class="otp-digit-input"
2721
+ [value]="otpDigits[i]"
2722
+ (input)="onOtpDigitInput($event, i)"
2723
+ (keydown)="onOtpDigitKeydown($event, i)"
2724
+ (paste)="onOtpPaste($event)">
2725
+ }
2726
+ </div>
2727
+ <div class="otp-actions">
2728
+ <button
2729
+ type="button"
2730
+ (click)="onOtpVerify()"
2731
+ [disabled]="loading || otpCode.length < 6"
2732
+ class="btn btn-primary btn-block">
2733
+ {{ loading ? 'Verifying...' : 'Verify' }}
2734
+ </button>
2735
+ </div>
2736
+ <div class="otp-resend">
2737
+ @if (otpResendCountdown > 0) {
2738
+ <span class="resend-timer">
2739
+ Resend in {{ formatCountdown(otpResendCountdown) }}
2740
+ </span>
2741
+ } @else {
2742
+ <a href="#" (click)="onOtpResend($event)" class="resend-link">
2743
+ Resend OTP
2744
+ </a>
2745
+ }
2746
+ </div>
2747
+ <div class="otp-back">
2748
+ <a href="#" (click)="onOtpBack($event)">
2749
+ Use a different email or phone
2750
+ </a>
2751
+ </div>
2752
+ </div>
2753
+ }
2754
+
2755
+ <!-- OTP Step 3: Registration (new user) -->
2756
+ @if (otpStep === 'register') {
2757
+ <div class="otp-register-section">
2758
+ <p class="otp-subtitle">
2759
+ Welcome! Enter your name to get started.
2760
+ </p>
2761
+ <form (ngSubmit)="onOtpRegister()" class="otp-form">
2762
+ <div class="form-group">
2763
+ <input
2764
+ [(ngModel)]="otpDisplayName"
2765
+ name="displayName"
2766
+ placeholder="Your Name"
2767
+ type="text"
2768
+ required
2769
+ class="form-control">
2770
+ </div>
2771
+ <button
2772
+ type="submit"
2773
+ [disabled]="loading || !otpDisplayName.trim()"
2774
+ class="btn btn-primary btn-block">
2775
+ {{ loading ? 'Creating account...' : 'Continue' }}
2776
+ </button>
2777
+ </form>
2778
+ </div>
2779
+ }
2780
+
2781
+ <!-- Divider before other providers -->
2782
+ @if (effectiveOauthProviders.length > 0 || isProviderEnabled('emailPassword')) {
2783
+ <div class="divider">
2784
+ <span>OR</span>
2785
+ </div>
2786
+ }
2787
+ }
2788
+
2274
2789
  <!-- Email/Password Form (if enabled) -->
2275
- @if (isProviderEnabled('emailPassword') && !useOAuth) {
2790
+ @if (isProviderEnabled('emailPassword') && !useOAuth && !otpActive) {
2276
2791
  <form (ngSubmit)="onEmailLogin()" class="email-form">
2277
2792
  <div class="form-group">
2278
2793
  <input
@@ -2296,7 +2811,7 @@ class TenantLoginComponent {
2296
2811
  class="password-toggle"
2297
2812
  (click)="showPassword = !showPassword"
2298
2813
  [attr.aria-label]="showPassword ? 'Hide password' : 'Show password'">
2299
- {{ showPassword ? '👁️' : '👁️‍🗨️' }}
2814
+ {{ showPassword ? '&#x1F441;' : '&#x1F441;&#x200D;&#x1F5E8;' }}
2300
2815
  </button>
2301
2816
  </div>
2302
2817
  <button
@@ -2308,7 +2823,7 @@ class TenantLoginComponent {
2308
2823
  </form>
2309
2824
 
2310
2825
  <!-- Divider -->
2311
- @if (oauthProviders.length > 0) {
2826
+ @if (effectiveOauthProviders.length > 0) {
2312
2827
  <div class="divider">
2313
2828
  <span>OR</span>
2314
2829
  </div>
@@ -2316,9 +2831,9 @@ class TenantLoginComponent {
2316
2831
  }
2317
2832
 
2318
2833
  <!-- OAuth Providers -->
2319
- @if (oauthProviders.length > 0 && (useOAuth || !isProviderEnabled('emailPassword'))) {
2834
+ @if (effectiveOauthProviders.length > 0 && !otpActive && (useOAuth || !isProviderEnabled('emailPassword'))) {
2320
2835
  <div class="oauth-buttons">
2321
- @for (provider of oauthProviders; track provider) {
2836
+ @for (provider of effectiveOauthProviders; track provider) {
2322
2837
  <button
2323
2838
  type="button"
2324
2839
  (click)="onOAuthLogin(provider)"
@@ -2336,7 +2851,7 @@ class TenantLoginComponent {
2336
2851
  </div>
2337
2852
 
2338
2853
  <!-- Switch to Email/Password -->
2339
- @if (isProviderEnabled('emailPassword') && oauthProviders.length > 0) {
2854
+ @if (isProviderEnabled('emailPassword') && effectiveOauthProviders.length > 0) {
2340
2855
  <div class="switch-method">
2341
2856
  <a href="#" (click)="toggleAuthMethod($event)">
2342
2857
  {{ useOAuth ? 'Use email/password instead' : 'Use OAuth instead' }}
@@ -2431,7 +2946,7 @@ class TenantLoginComponent {
2431
2946
  </div>
2432
2947
  }
2433
2948
  </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"] }] });
2949
+ `, 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
2950
  }
2436
2951
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: TenantLoginComponent, decorators: [{
2437
2952
  type: Component,
@@ -2441,8 +2956,119 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
2441
2956
  <!-- Step 1: Authentication -->
2442
2957
  <h2 class="login-title">{{ title }}</h2>
2443
2958
 
2959
+ <!-- OTP Flow -->
2960
+ @if (isProviderEnabled('otp') && otpActive) {
2961
+ <!-- OTP Step 1: Identifier entry -->
2962
+ @if (otpStep === 'identifier') {
2963
+ <form (ngSubmit)="onOtpSend()" class="otp-form">
2964
+ <div class="form-group">
2965
+ <input
2966
+ [(ngModel)]="otpIdentifier"
2967
+ name="otpIdentifier"
2968
+ placeholder="Enter Email or Phone Number"
2969
+ type="text"
2970
+ required
2971
+ autocomplete="email tel"
2972
+ class="form-control"
2973
+ (input)="onOtpIdentifierChange()">
2974
+ @if (otpIdentifierHint) {
2975
+ <div class="field-hint">{{ otpIdentifierHint }}</div>
2976
+ }
2977
+ </div>
2978
+ <button
2979
+ type="submit"
2980
+ [disabled]="loading || !otpIdentifier.trim()"
2981
+ class="btn btn-primary btn-block">
2982
+ {{ loading ? 'Sending...' : 'Send OTP' }}
2983
+ </button>
2984
+ </form>
2985
+ }
2986
+
2987
+ <!-- OTP Step 2: Code entry -->
2988
+ @if (otpStep === 'code') {
2989
+ <div class="otp-code-section">
2990
+ <p class="otp-subtitle">
2991
+ Enter the 6-digit code sent to
2992
+ <strong>{{ otpMaskedIdentifier }}</strong>
2993
+ </p>
2994
+ <div class="otp-digits">
2995
+ @for (digit of otpDigits; track $index; let i = $index) {
2996
+ <input
2997
+ #otpInput
2998
+ type="text"
2999
+ inputmode="numeric"
3000
+ maxlength="1"
3001
+ class="otp-digit-input"
3002
+ [value]="otpDigits[i]"
3003
+ (input)="onOtpDigitInput($event, i)"
3004
+ (keydown)="onOtpDigitKeydown($event, i)"
3005
+ (paste)="onOtpPaste($event)">
3006
+ }
3007
+ </div>
3008
+ <div class="otp-actions">
3009
+ <button
3010
+ type="button"
3011
+ (click)="onOtpVerify()"
3012
+ [disabled]="loading || otpCode.length < 6"
3013
+ class="btn btn-primary btn-block">
3014
+ {{ loading ? 'Verifying...' : 'Verify' }}
3015
+ </button>
3016
+ </div>
3017
+ <div class="otp-resend">
3018
+ @if (otpResendCountdown > 0) {
3019
+ <span class="resend-timer">
3020
+ Resend in {{ formatCountdown(otpResendCountdown) }}
3021
+ </span>
3022
+ } @else {
3023
+ <a href="#" (click)="onOtpResend($event)" class="resend-link">
3024
+ Resend OTP
3025
+ </a>
3026
+ }
3027
+ </div>
3028
+ <div class="otp-back">
3029
+ <a href="#" (click)="onOtpBack($event)">
3030
+ Use a different email or phone
3031
+ </a>
3032
+ </div>
3033
+ </div>
3034
+ }
3035
+
3036
+ <!-- OTP Step 3: Registration (new user) -->
3037
+ @if (otpStep === 'register') {
3038
+ <div class="otp-register-section">
3039
+ <p class="otp-subtitle">
3040
+ Welcome! Enter your name to get started.
3041
+ </p>
3042
+ <form (ngSubmit)="onOtpRegister()" class="otp-form">
3043
+ <div class="form-group">
3044
+ <input
3045
+ [(ngModel)]="otpDisplayName"
3046
+ name="displayName"
3047
+ placeholder="Your Name"
3048
+ type="text"
3049
+ required
3050
+ class="form-control">
3051
+ </div>
3052
+ <button
3053
+ type="submit"
3054
+ [disabled]="loading || !otpDisplayName.trim()"
3055
+ class="btn btn-primary btn-block">
3056
+ {{ loading ? 'Creating account...' : 'Continue' }}
3057
+ </button>
3058
+ </form>
3059
+ </div>
3060
+ }
3061
+
3062
+ <!-- Divider before other providers -->
3063
+ @if (effectiveOauthProviders.length > 0 || isProviderEnabled('emailPassword')) {
3064
+ <div class="divider">
3065
+ <span>OR</span>
3066
+ </div>
3067
+ }
3068
+ }
3069
+
2444
3070
  <!-- Email/Password Form (if enabled) -->
2445
- @if (isProviderEnabled('emailPassword') && !useOAuth) {
3071
+ @if (isProviderEnabled('emailPassword') && !useOAuth && !otpActive) {
2446
3072
  <form (ngSubmit)="onEmailLogin()" class="email-form">
2447
3073
  <div class="form-group">
2448
3074
  <input
@@ -2466,7 +3092,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
2466
3092
  class="password-toggle"
2467
3093
  (click)="showPassword = !showPassword"
2468
3094
  [attr.aria-label]="showPassword ? 'Hide password' : 'Show password'">
2469
- {{ showPassword ? '👁️' : '👁️‍🗨️' }}
3095
+ {{ showPassword ? '&#x1F441;' : '&#x1F441;&#x200D;&#x1F5E8;' }}
2470
3096
  </button>
2471
3097
  </div>
2472
3098
  <button
@@ -2478,7 +3104,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
2478
3104
  </form>
2479
3105
 
2480
3106
  <!-- Divider -->
2481
- @if (oauthProviders.length > 0) {
3107
+ @if (effectiveOauthProviders.length > 0) {
2482
3108
  <div class="divider">
2483
3109
  <span>OR</span>
2484
3110
  </div>
@@ -2486,9 +3112,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
2486
3112
  }
2487
3113
 
2488
3114
  <!-- OAuth Providers -->
2489
- @if (oauthProviders.length > 0 && (useOAuth || !isProviderEnabled('emailPassword'))) {
3115
+ @if (effectiveOauthProviders.length > 0 && !otpActive && (useOAuth || !isProviderEnabled('emailPassword'))) {
2490
3116
  <div class="oauth-buttons">
2491
- @for (provider of oauthProviders; track provider) {
3117
+ @for (provider of effectiveOauthProviders; track provider) {
2492
3118
  <button
2493
3119
  type="button"
2494
3120
  (click)="onOAuthLogin(provider)"
@@ -2506,7 +3132,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
2506
3132
  </div>
2507
3133
 
2508
3134
  <!-- Switch to Email/Password -->
2509
- @if (isProviderEnabled('emailPassword') && oauthProviders.length > 0) {
3135
+ @if (isProviderEnabled('emailPassword') && effectiveOauthProviders.length > 0) {
2510
3136
  <div class="switch-method">
2511
3137
  <a href="#" (click)="toggleAuthMethod($event)">
2512
3138
  {{ useOAuth ? 'Use email/password instead' : 'Use OAuth instead' }}
@@ -2601,8 +3227,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
2601
3227
  </div>
2602
3228
  }
2603
3229
  </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: [{
3230
+ `, 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"] }]
3231
+ }], ctorParameters: () => [{ type: AuthService }, { type: ProviderRegistryService }], propDecorators: { otpInputs: [{
3232
+ type: ViewChildren,
3233
+ args: ['otpInput']
3234
+ }], title: [{
2606
3235
  type: Input
2607
3236
  }], providers: [{
2608
3237
  type: Input
@@ -3406,8 +4035,8 @@ class MonthYearPickerComponent {
3406
4035
  isOpen = signal(false, ...(ngDevMode ? [{ debugName: "isOpen" }] : []));
3407
4036
  tempMonth = signal(null, ...(ngDevMode ? [{ debugName: "tempMonth" }] : []));
3408
4037
  tempYear = signal(null, ...(ngDevMode ? [{ debugName: "tempYear" }] : []));
3409
- yearStart = signal(this.today.year - 4, ...(ngDevMode ? [{ debugName: "yearStart" }] : []));
3410
- years = computed(() => Array.from({ length: 12 }, (_, i) => this.yearStart() + i), ...(ngDevMode ? [{ debugName: "years" }] : []));
4038
+ yearStart = signal(this.today.year - 3, ...(ngDevMode ? [{ debugName: "yearStart" }] : []));
4039
+ years = computed(() => Array.from({ length: 10 }, (_, i) => this.yearStart() + i), ...(ngDevMode ? [{ debugName: "years" }] : []));
3411
4040
  displayValue = computed(() => {
3412
4041
  const v = this.value();
3413
4042
  return v ? `${this.MONTHS[v.month]} ${v.year}` : '— / —';
@@ -3468,11 +4097,12 @@ class MonthYearPickerComponent {
3468
4097
  <span class="font-monospace fw-semibold">{{ preview() }}</span>
3469
4098
  </div>
3470
4099
 
3471
- <div class="px-2 pt-2 pb-1">
3472
- <div class="text-uppercase text-muted px-1 mb-1" style="font-size: 10px; letter-spacing: .08em;">Month</div>
3473
- <div class="row row-cols-4 g-1">
3474
- @for (month of MONTHS; track $index) {
3475
- <div class="col">
4100
+ <div class="nsx-myp-body">
4101
+ <!-- Months: 6 rows × 2 cols -->
4102
+ <div class="nsx-myp-section">
4103
+ <div class="text-uppercase text-muted px-1 mb-1" style="font-size: 10px; letter-spacing: .08em;">Month</div>
4104
+ <div class="nsx-myp-grid nsx-myp-grid-2">
4105
+ @for (month of MONTHS; track $index) {
3476
4106
  <button
3477
4107
  type="button"
3478
4108
  class="btn btn-sm w-100"
@@ -3482,16 +4112,15 @@ class MonthYearPickerComponent {
3482
4112
  (click)="selectMonth($index)">
3483
4113
  {{ month }}
3484
4114
  </button>
3485
- </div>
3486
- }
4115
+ }
4116
+ </div>
3487
4117
  </div>
3488
- </div>
3489
4118
 
3490
- <div class="px-2 pt-1 pb-2">
3491
- <div class="text-uppercase text-muted px-1 mb-1" style="font-size: 10px; letter-spacing: .08em;">Year</div>
3492
- <div class="row row-cols-4 g-1">
3493
- @for (year of years(); track year) {
3494
- <div class="col">
4119
+ <!-- Years: 5 rows × 2 cols + nav row -->
4120
+ <div class="nsx-myp-section">
4121
+ <div class="text-uppercase text-muted px-1 mb-1" style="font-size: 10px; letter-spacing: .08em;">Year</div>
4122
+ <div class="nsx-myp-grid nsx-myp-grid-2">
4123
+ @for (year of years(); track year) {
3495
4124
  <button
3496
4125
  type="button"
3497
4126
  class="btn btn-sm w-100 font-monospace"
@@ -3501,19 +4130,12 @@ class MonthYearPickerComponent {
3501
4130
  (click)="selectYear(year)">
3502
4131
  {{ year }}
3503
4132
  </button>
3504
- </div>
3505
- }
3506
- </div>
3507
-
3508
- <div class="row g-1 mt-1">
3509
- <div class="col">
3510
- <button type="button" class="btn btn-sm btn-light w-100" (click)="shiftYears(-12)">
3511
- &lsaquo; Earlier
4133
+ }
4134
+ <button type="button" class="btn btn-sm btn-light w-100" (click)="shiftYears(-10)">
4135
+ &lsaquo;
3512
4136
  </button>
3513
- </div>
3514
- <div class="col">
3515
- <button type="button" class="btn btn-sm btn-light w-100" (click)="shiftYears(12)">
3516
- Later &rsaquo;
4137
+ <button type="button" class="btn btn-sm btn-light w-100" (click)="shiftYears(10)">
4138
+ &rsaquo;
3517
4139
  </button>
3518
4140
  </div>
3519
4141
  </div>
@@ -3523,24 +4145,24 @@ class MonthYearPickerComponent {
3523
4145
  <div class="col border-end">
3524
4146
  <button
3525
4147
  type="button"
3526
- class="btn w-100 rounded-0 py-2 fw-semibold text-success"
3527
- (click)="confirm()">
3528
- OK
4148
+ class="btn btn-light w-100 rounded-0 py-2"
4149
+ (click)="cancel()">
4150
+ Cancel
3529
4151
  </button>
3530
4152
  </div>
3531
4153
  <div class="col">
3532
4154
  <button
3533
4155
  type="button"
3534
- class="btn btn-light w-100 rounded-0 py-2"
3535
- (click)="cancel()">
3536
- Cancel
4156
+ class="btn w-100 rounded-0 py-2 fw-semibold text-success"
4157
+ (click)="confirm()">
4158
+ OK
3537
4159
  </button>
3538
4160
  </div>
3539
4161
  </div>
3540
4162
 
3541
4163
  </div>
3542
4164
  }
3543
- `, isInline: true, styles: [":host{position:relative;display:block}.nsx-myp-backdrop{position:fixed;inset:0;z-index:1050}.nsx-myp-popup{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:1051;background:#fff;width:300px;max-width:92vw;overflow:hidden}\n"] });
4165
+ `, isInline: true, styles: [":host{position:relative;display:block}.nsx-myp-backdrop{position:fixed;inset:0;z-index:1050}.nsx-myp-popup{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:1051;background:#fff;width:340px;max-width:92vw;overflow:hidden}.nsx-myp-body{display:flex;gap:.5rem;padding:.5rem}.nsx-myp-section{flex:1;min-width:0}.nsx-myp-grid{display:grid;gap:3px}.nsx-myp-grid-2{grid-template-columns:1fr 1fr}\n"] });
3544
4166
  }
3545
4167
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MonthYearPickerComponent, decorators: [{
3546
4168
  type: Component,
@@ -3566,11 +4188,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
3566
4188
  <span class="font-monospace fw-semibold">{{ preview() }}</span>
3567
4189
  </div>
3568
4190
 
3569
- <div class="px-2 pt-2 pb-1">
3570
- <div class="text-uppercase text-muted px-1 mb-1" style="font-size: 10px; letter-spacing: .08em;">Month</div>
3571
- <div class="row row-cols-4 g-1">
3572
- @for (month of MONTHS; track $index) {
3573
- <div class="col">
4191
+ <div class="nsx-myp-body">
4192
+ <!-- Months: 6 rows × 2 cols -->
4193
+ <div class="nsx-myp-section">
4194
+ <div class="text-uppercase text-muted px-1 mb-1" style="font-size: 10px; letter-spacing: .08em;">Month</div>
4195
+ <div class="nsx-myp-grid nsx-myp-grid-2">
4196
+ @for (month of MONTHS; track $index) {
3574
4197
  <button
3575
4198
  type="button"
3576
4199
  class="btn btn-sm w-100"
@@ -3580,16 +4203,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
3580
4203
  (click)="selectMonth($index)">
3581
4204
  {{ month }}
3582
4205
  </button>
3583
- </div>
3584
- }
4206
+ }
4207
+ </div>
3585
4208
  </div>
3586
- </div>
3587
4209
 
3588
- <div class="px-2 pt-1 pb-2">
3589
- <div class="text-uppercase text-muted px-1 mb-1" style="font-size: 10px; letter-spacing: .08em;">Year</div>
3590
- <div class="row row-cols-4 g-1">
3591
- @for (year of years(); track year) {
3592
- <div class="col">
4210
+ <!-- Years: 5 rows × 2 cols + nav row -->
4211
+ <div class="nsx-myp-section">
4212
+ <div class="text-uppercase text-muted px-1 mb-1" style="font-size: 10px; letter-spacing: .08em;">Year</div>
4213
+ <div class="nsx-myp-grid nsx-myp-grid-2">
4214
+ @for (year of years(); track year) {
3593
4215
  <button
3594
4216
  type="button"
3595
4217
  class="btn btn-sm w-100 font-monospace"
@@ -3599,19 +4221,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
3599
4221
  (click)="selectYear(year)">
3600
4222
  {{ year }}
3601
4223
  </button>
3602
- </div>
3603
- }
3604
- </div>
3605
-
3606
- <div class="row g-1 mt-1">
3607
- <div class="col">
3608
- <button type="button" class="btn btn-sm btn-light w-100" (click)="shiftYears(-12)">
3609
- &lsaquo; Earlier
4224
+ }
4225
+ <button type="button" class="btn btn-sm btn-light w-100" (click)="shiftYears(-10)">
4226
+ &lsaquo;
3610
4227
  </button>
3611
- </div>
3612
- <div class="col">
3613
- <button type="button" class="btn btn-sm btn-light w-100" (click)="shiftYears(12)">
3614
- Later &rsaquo;
4228
+ <button type="button" class="btn btn-sm btn-light w-100" (click)="shiftYears(10)">
4229
+ &rsaquo;
3615
4230
  </button>
3616
4231
  </div>
3617
4232
  </div>
@@ -3621,24 +4236,24 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
3621
4236
  <div class="col border-end">
3622
4237
  <button
3623
4238
  type="button"
3624
- class="btn w-100 rounded-0 py-2 fw-semibold text-success"
3625
- (click)="confirm()">
3626
- OK
4239
+ class="btn btn-light w-100 rounded-0 py-2"
4240
+ (click)="cancel()">
4241
+ Cancel
3627
4242
  </button>
3628
4243
  </div>
3629
4244
  <div class="col">
3630
4245
  <button
3631
4246
  type="button"
3632
- class="btn btn-light w-100 rounded-0 py-2"
3633
- (click)="cancel()">
3634
- Cancel
4247
+ class="btn w-100 rounded-0 py-2 fw-semibold text-success"
4248
+ (click)="confirm()">
4249
+ OK
3635
4250
  </button>
3636
4251
  </div>
3637
4252
  </div>
3638
4253
 
3639
4254
  </div>
3640
4255
  }
3641
- `, styles: [":host{position:relative;display:block}.nsx-myp-backdrop{position:fixed;inset:0;z-index:1050}.nsx-myp-popup{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:1051;background:#fff;width:300px;max-width:92vw;overflow:hidden}\n"] }]
4256
+ `, styles: [":host{position:relative;display:block}.nsx-myp-backdrop{position:fixed;inset:0;z-index:1050}.nsx-myp-popup{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:1051;background:#fff;width:340px;max-width:92vw;overflow:hidden}.nsx-myp-body{display:flex;gap:.5rem;padding:.5rem}.nsx-myp-section{flex:1;min-width:0}.nsx-myp-grid{display:grid;gap:3px}.nsx-myp-grid-2{grid-template-columns:1fr 1fr}\n"] }]
3642
4257
  }], propDecorators: { label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }], valueChange: [{ type: i0.Output, args: ["valueChange"] }] } });
3643
4258
 
3644
4259
  class TenantRegisterComponent {