@nauticana/sail 0.5.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.
Files changed (70) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +741 -0
  3. package/package.json +58 -0
  4. package/src/component/abstract/base_async.ts +49 -0
  5. package/src/component/abstract/base_form.ts +11 -0
  6. package/src/component/abstract/base_table.ts +320 -0
  7. package/src/component/abstract/base_view.ts +93 -0
  8. package/src/component/billing/checkout_button.html +10 -0
  9. package/src/component/billing/checkout_button.ts +57 -0
  10. package/src/component/billing/payment_methods.html +27 -0
  11. package/src/component/billing/payment_methods.ts +36 -0
  12. package/src/component/billing/plan_selector.html +24 -0
  13. package/src/component/billing/plan_selector.ts +29 -0
  14. package/src/component/form/form_field.html +54 -0
  15. package/src/component/form/form_field.ts +156 -0
  16. package/src/component/form/form_record.html +14 -0
  17. package/src/component/form/form_record.ts +56 -0
  18. package/src/component/form/form_table.html +19 -0
  19. package/src/component/form/form_table.ts +58 -0
  20. package/src/component/login/account_deletion.html +42 -0
  21. package/src/component/login/account_deletion.ts +79 -0
  22. package/src/component/login/chpass_component.html +58 -0
  23. package/src/component/login/chpass_component.ts +96 -0
  24. package/src/component/login/confirm_chpass_component.html +47 -0
  25. package/src/component/login/confirm_chpass_component.ts +70 -0
  26. package/src/component/login/confirm_register_component.html +37 -0
  27. package/src/component/login/confirm_register_component.ts +71 -0
  28. package/src/component/login/consent_gate.html +30 -0
  29. package/src/component/login/consent_gate.ts +109 -0
  30. package/src/component/login/login_component.html +43 -0
  31. package/src/component/login/login_component.ts +73 -0
  32. package/src/component/login/otp_input.html +38 -0
  33. package/src/component/login/otp_input.ts +104 -0
  34. package/src/component/login/register_component.html +132 -0
  35. package/src/component/login/register_component.ts +184 -0
  36. package/src/component/login/social_login.html +13 -0
  37. package/src/component/login/social_login.ts +120 -0
  38. package/src/component/navigation/navigation.html +49 -0
  39. package/src/component/navigation/navigation.ts +69 -0
  40. package/src/component/security/trusted_devices.html +76 -0
  41. package/src/component/security/trusted_devices.ts +92 -0
  42. package/src/component/security/twofactor_setup.html +75 -0
  43. package/src/component/security/twofactor_setup.ts +106 -0
  44. package/src/component/security/twofactor_verify.html +45 -0
  45. package/src/component/security/twofactor_verify.ts +90 -0
  46. package/src/component/table/table_detail.html +53 -0
  47. package/src/component/table/table_detail.ts +133 -0
  48. package/src/component/table/table_edit.html +58 -0
  49. package/src/component/table/table_edit.ts +163 -0
  50. package/src/component/table/table_list.html +41 -0
  51. package/src/component/table/table_list.ts +100 -0
  52. package/src/component/table/table_lookup.html +35 -0
  53. package/src/component/table/table_lookup.ts +88 -0
  54. package/src/component/table/table_search.html +17 -0
  55. package/src/component/table/table_search.ts +80 -0
  56. package/src/config.ts +67 -0
  57. package/src/index.ts +41 -0
  58. package/src/model/appdata.ts +266 -0
  59. package/src/model/auth.ts +134 -0
  60. package/src/model/common.ts +115 -0
  61. package/src/model/decorator.ts +74 -0
  62. package/src/service/api-response.interceptor.ts +26 -0
  63. package/src/service/auth.interceptor.ts +11 -0
  64. package/src/service/auth.service.ts +576 -0
  65. package/src/service/billing.service.ts +65 -0
  66. package/src/service/label.service.ts +18 -0
  67. package/src/service/rest_service.ts +94 -0
  68. package/src/service/rest_url.ts +42 -0
  69. package/src/service/script_loader.ts +31 -0
  70. package/tsconfig.json +16 -0
@@ -0,0 +1,104 @@
1
+ import {
2
+ ChangeDetectionStrategy, Component, DestroyRef, OnInit, ViewEncapsulation,
3
+ computed, effect, inject, input, output, signal,
4
+ } from '@angular/core';
5
+ import { MatButtonModule } from '@angular/material/button';
6
+ import { MatIconModule } from '@angular/material/icon';
7
+
8
+ /**
9
+ * OtpInput — pure-presentational numeric OTP keypad with resend countdown.
10
+ *
11
+ * Owns no service layer. The parent component sends the OTP (via
12
+ * BaseAuthService.sendOtp) and subscribes to `codeComplete` / `resend` to
13
+ * call verifyOtp / resendOtp. See README "Phone / email OTP login".
14
+ */
15
+ @Component({
16
+ selector: 'app-otp-input',
17
+ templateUrl: './otp_input.html',
18
+ changeDetection: ChangeDetectionStrategy.OnPush,
19
+ encapsulation: ViewEncapsulation.None,
20
+ imports: [MatButtonModule, MatIconModule],
21
+ })
22
+ export class OtpInputComponent implements OnInit {
23
+ private readonly destroyRef = inject(DestroyRef);
24
+
25
+ readonly length = input(6);
26
+ readonly contact = input('');
27
+ readonly resendCountdownSec = input(30);
28
+ readonly disabled = input(false);
29
+
30
+ readonly codeComplete = output<string>();
31
+ readonly resend = output<void>();
32
+
33
+ readonly digits = signal<string[]>([]);
34
+ readonly countdown = signal(0);
35
+ readonly canResend = signal(false);
36
+
37
+ private countdownTimer: ReturnType<typeof setInterval> | null = null;
38
+
39
+ readonly currentIndex = computed(() => {
40
+ const d = this.digits();
41
+ const idx = d.findIndex((v) => v === '');
42
+ return idx === -1 ? d.length - 1 : idx;
43
+ });
44
+
45
+ readonly keypadKeys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '', '0', 'back'] as const;
46
+
47
+ constructor() {
48
+ // Reset the digits array whenever `length` input changes.
49
+ effect(() => { this.digits.set(Array(this.length()).fill('')); });
50
+ this.destroyRef.onDestroy(() => this.clearTimer());
51
+ }
52
+
53
+ ngOnInit() {
54
+ this.startCountdown();
55
+ }
56
+
57
+ onKeyPress(key: string) {
58
+ if (this.disabled()) return;
59
+ const d = [...this.digits()];
60
+ const idx = d.findIndex((v) => v === '');
61
+ if (idx === -1) return;
62
+ d[idx] = key;
63
+ this.digits.set(d);
64
+ if (idx === this.length() - 1) this.codeComplete.emit(d.join(''));
65
+ }
66
+
67
+ onBackspace() {
68
+ if (this.disabled()) return;
69
+ const d = [...this.digits()];
70
+ let idx = d.findIndex((v) => v === '');
71
+ if (idx === -1) idx = d.length;
72
+ if (idx > 0) {
73
+ d[idx - 1] = '';
74
+ this.digits.set(d);
75
+ }
76
+ }
77
+
78
+ onResend() {
79
+ this.resend.emit();
80
+ this.digits.set(Array(this.length()).fill(''));
81
+ this.startCountdown();
82
+ }
83
+
84
+ private startCountdown() {
85
+ this.clearTimer();
86
+ this.countdown.set(this.resendCountdownSec());
87
+ this.canResend.set(false);
88
+ this.countdownTimer = setInterval(() => {
89
+ const next = this.countdown() - 1;
90
+ this.countdown.set(next);
91
+ if (next <= 0) {
92
+ this.canResend.set(true);
93
+ this.clearTimer();
94
+ }
95
+ }, 1000);
96
+ }
97
+
98
+ private clearTimer() {
99
+ if (this.countdownTimer) {
100
+ clearInterval(this.countdownTimer);
101
+ this.countdownTimer = null;
102
+ }
103
+ }
104
+ }
@@ -0,0 +1,132 @@
1
+ <div class="auth-container">
2
+ @if (guiConfig.appTitle) {
3
+ <div class="auth-header">
4
+ <h1 class="auth-app-title">{{ guiConfig.appTitle }}</h1>
5
+ </div>
6
+ }
7
+ <div class="auth-card register-card">
8
+ <h2 class="auth-title">Create Your Account</h2>
9
+ <form [formGroup]="registerForm" (ngSubmit)="register()" class="auth-form">
10
+
11
+ <div class="auth-section-label">Account</div>
12
+
13
+ <div class="form-row">
14
+ <mat-form-field appearance="outline">
15
+ <mat-label>First Name</mat-label>
16
+ <input matInput type="text" formControlName="FirstName" required>
17
+ </mat-form-field>
18
+
19
+ <mat-form-field appearance="outline">
20
+ <mat-label>Last Name</mat-label>
21
+ <input matInput type="text" formControlName="LastName" required>
22
+ </mat-form-field>
23
+ </div>
24
+
25
+ <mat-form-field appearance="outline">
26
+ <mat-label>Email</mat-label>
27
+ <input matInput type="email" formControlName="Email" required>
28
+ </mat-form-field>
29
+
30
+ <div class="form-row">
31
+ <mat-form-field appearance="outline">
32
+ <mat-label>Username</mat-label>
33
+ <input matInput type="text" formControlName="UserName" required>
34
+ </mat-form-field>
35
+
36
+ <mat-form-field appearance="outline">
37
+ <mat-label>Password</mat-label>
38
+ <input matInput type="password" formControlName="Password" required>
39
+ </mat-form-field>
40
+ </div>
41
+
42
+ <div class="auth-section-label">Plan</div>
43
+
44
+ <mat-form-field appearance="outline">
45
+ <mat-label>Subscription plan</mat-label>
46
+ <mat-select formControlName="PlanID" required>
47
+ @for (p of plans(); track p.id) {
48
+ <mat-option [value]="p.id">
49
+ {{ p.caption }}@if (p.monthlyCost > 0) { — \${{ p.monthlyCost }}/mo }
50
+ </mat-option>
51
+ }
52
+ </mat-select>
53
+ </mat-form-field>
54
+
55
+ <div class="auth-section-label">Business Information</div>
56
+
57
+ <mat-form-field appearance="outline">
58
+ <mat-label>Business/Company Name</mat-label>
59
+ <input matInput type="text" formControlName="PartnerCaption" required>
60
+ </mat-form-field>
61
+
62
+ <div class="form-row">
63
+ <mat-form-field appearance="outline">
64
+ <mat-label>Phone</mat-label>
65
+ <input matInput type="tel" formControlName="Phone" required>
66
+ </mat-form-field>
67
+
68
+ <mat-form-field appearance="outline">
69
+ <mat-label>Website URL</mat-label>
70
+ <input matInput type="url" formControlName="DomainURL" required>
71
+ </mat-form-field>
72
+ </div>
73
+
74
+ <div class="auth-section-label">Location</div>
75
+
76
+ <mat-form-field appearance="outline">
77
+ <mat-label>Address (Street and Number)</mat-label>
78
+ <input matInput type="text" formControlName="Address" required>
79
+ </mat-form-field>
80
+
81
+ <div class="form-row">
82
+ <mat-form-field appearance="outline">
83
+ <mat-label>City</mat-label>
84
+ <input matInput type="text" formControlName="City" required>
85
+ </mat-form-field>
86
+
87
+ <mat-form-field appearance="outline">
88
+ <mat-label>State</mat-label>
89
+ <input matInput type="text" formControlName="State" required>
90
+ </mat-form-field>
91
+ </div>
92
+
93
+ <div class="form-row">
94
+ <mat-form-field appearance="outline">
95
+ <mat-label>Zipcode</mat-label>
96
+ <input matInput type="text" formControlName="Zipcode" required>
97
+ </mat-form-field>
98
+
99
+ <mat-form-field appearance="outline">
100
+ <mat-label>Country</mat-label>
101
+ <input matInput type="text" formControlName="Country" required>
102
+ </mat-form-field>
103
+ </div>
104
+
105
+ <div class="auth-checkbox">
106
+ <mat-checkbox formControlName="consentLocation">
107
+ I consent to geocode my business address for location services
108
+ </mat-checkbox>
109
+ </div>
110
+
111
+ @if (geocodeStatus() === 'loading') {
112
+ <div class="geocode-status geocode-loading" role="status">Verifying address...</div>
113
+ } @else if (geocodeStatus() === 'success') {
114
+ <div class="geocode-status geocode-success" role="status">Address verified.</div>
115
+ } @else if (geocodeStatus() === 'error') {
116
+ <div class="geocode-status geocode-error" role="alert">{{ geocodeError() }}</div>
117
+ }
118
+
119
+ @if (registerError()) {
120
+ <div class="geocode-status geocode-error" role="alert">{{ registerError() }}</div>
121
+ }
122
+
123
+ <button mat-raised-button class="primary" type="submit"
124
+ [disabled]="!registerForm.valid || geocodeStatus() !== 'success'">
125
+ Create Account
126
+ </button>
127
+ </form>
128
+ <div class="auth-actions">
129
+ <a routerLink="/login/local">Already have an account? Sign in</a>
130
+ </div>
131
+ </div>
132
+ </div>
@@ -0,0 +1,184 @@
1
+ import { ChangeDetectionStrategy, Component, inject, effect, DestroyRef, signal, ViewEncapsulation, OnInit } from '@angular/core';
2
+ import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
3
+ import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
4
+ import { Router, RouterLink } from '@angular/router';
5
+ import { BaseAuthService } from '../../service/auth.service';
6
+ import { BillingService } from '../../service/billing.service';
7
+ import { MatButtonModule } from '@angular/material/button';
8
+ import { MatFormFieldModule } from "@angular/material/form-field";
9
+ import { MatInputModule } from '@angular/material/input';
10
+ import { MatCheckboxModule } from '@angular/material/checkbox';
11
+ import { MatSelectModule } from '@angular/material/select';
12
+ import { firstValueFrom, map, merge } from 'rxjs';
13
+ import { HttpClient } from '@angular/common/http';
14
+ import { PartnerRegistration, PublicPlan } from '../../model/appdata';
15
+ import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
16
+
17
+ @Component({
18
+ selector: 'app-register',
19
+ templateUrl: './register_component.html',
20
+ changeDetection: ChangeDetectionStrategy.OnPush,
21
+ encapsulation: ViewEncapsulation.None,
22
+ imports: [
23
+ ReactiveFormsModule,
24
+ RouterLink,
25
+ MatButtonModule,
26
+ MatFormFieldModule,
27
+ MatInputModule,
28
+ MatCheckboxModule,
29
+ MatSelectModule,
30
+ ],
31
+ })
32
+ export class RegisterComponent implements OnInit {
33
+ private auth = inject(BaseAuthService);
34
+ private billing = inject(BillingService);
35
+ private fb = inject(FormBuilder);
36
+ private http = inject(HttpClient);
37
+ private router = inject(Router);
38
+ protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
39
+
40
+ readonly registerError = signal('');
41
+ readonly plans = signal<PublicPlan[]>([]);
42
+
43
+ readonly registerForm = this.fb.group({
44
+ FirstName: ['', Validators.required],
45
+ LastName: ['', Validators.required],
46
+ UserName: ['', Validators.required],
47
+ Email: ['', Validators.required],
48
+ Password: ['', Validators.required],
49
+ PartnerCaption: ['', Validators.required],
50
+ Address: ['', Validators.required],
51
+ City: ['', Validators.required],
52
+ State: ['', Validators.required],
53
+ Zipcode: ['', Validators.required],
54
+ Country: ['', Validators.required],
55
+ Phone: ['', Validators.required],
56
+ DomainURL: ['', Validators.required],
57
+ PlanID: ['FREE', Validators.required],
58
+ Latitude: [null as number | null],
59
+ Longitude: [null as number | null],
60
+ consentLocation: [false, Validators.requiredTrue],
61
+ });
62
+
63
+ readonly geocodeStatus = signal<'idle' | 'loading' | 'success' | 'error'>('idle');
64
+ readonly geocodeError = signal('');
65
+
66
+ private consentLocation = toSignal(
67
+ this.registerForm.controls.consentLocation.valueChanges,
68
+ { initialValue: false }
69
+ );
70
+
71
+ private destroyRef = inject(DestroyRef);
72
+
73
+ private addressFields = ['Address', 'City', 'State', 'Zipcode', 'Country'] as const;
74
+
75
+ protected getGoogleMapsApiKey(): string {
76
+ return this.guiConfig.googleMapsApiKey ?? '';
77
+ }
78
+
79
+ ngOnInit() {
80
+ this.billing.listPlans().subscribe({
81
+ next: (list) => this.plans.set(list ?? []),
82
+ error: () => this.plans.set([]),
83
+ });
84
+ }
85
+
86
+ constructor() {
87
+ effect(() => {
88
+ const consent = this.consentLocation();
89
+ if (consent) {
90
+ this.tryGeocode();
91
+ } else {
92
+ this.registerForm.patchValue({ Latitude: null, Longitude: null });
93
+ this.geocodeStatus.set('idle');
94
+ this.geocodeError.set('');
95
+ }
96
+ });
97
+
98
+ merge(
99
+ ...this.addressFields.map((f) => this.registerForm.controls[f].valueChanges),
100
+ )
101
+ .pipe(takeUntilDestroyed(this.destroyRef))
102
+ .subscribe(() => {
103
+ this.registerForm.patchValue({ Latitude: null, Longitude: null });
104
+ if (this.consentLocation()) {
105
+ this.tryGeocode();
106
+ }
107
+ });
108
+ }
109
+
110
+ private async tryGeocode() {
111
+ const { Address, City, State, Zipcode, Country } = this.registerForm.getRawValue();
112
+ if (!Address || !City || !State || !Zipcode || !Country) {
113
+ this.geocodeStatus.set('idle');
114
+ this.geocodeError.set('');
115
+ return;
116
+ }
117
+ this.geocodeStatus.set('loading');
118
+ this.geocodeError.set('');
119
+ try {
120
+ const coords = await this.getCoordinates(`${Address}, ${City}, ${State} ${Zipcode}, ${Country}`);
121
+ this.registerForm.patchValue({ Latitude: coords.lat, Longitude: coords.lng });
122
+ this.geocodeStatus.set('success');
123
+ } catch {
124
+ this.registerForm.patchValue({ Latitude: null, Longitude: null });
125
+ this.geocodeStatus.set('error');
126
+ this.geocodeError.set('Could not verify this address. Please check and try again.');
127
+ }
128
+ }
129
+
130
+ register() {
131
+ if (this.registerForm.valid) {
132
+ this.registerError.set('');
133
+ const formValue = this.registerForm.getRawValue();
134
+ const partnerReg: PartnerRegistration = {
135
+ FirstName: formValue.FirstName!,
136
+ LastName: formValue.LastName!,
137
+ UserName: formValue.UserName!,
138
+ Email: formValue.Email!,
139
+ Password: formValue.Password!,
140
+ PartnerCaption: formValue.PartnerCaption!,
141
+ Address: formValue.Address!,
142
+ City: formValue.City!,
143
+ State: formValue.State!,
144
+ Zipcode: formValue.Zipcode!,
145
+ Country: formValue.Country!,
146
+ Phone: formValue.Phone!,
147
+ DomainURL: formValue.DomainURL!,
148
+ Latitude: formValue.Latitude!,
149
+ Longitude: formValue.Longitude!,
150
+ PlanID: formValue.PlanID!,
151
+ };
152
+ this.auth.register(partnerReg).subscribe({
153
+ next: () => this.router.navigate(['/confirm/register']),
154
+ error: (err) => this.registerError.set(err?.error?.message ?? 'Registration failed. Please try again.'),
155
+ });
156
+ }
157
+ }
158
+
159
+ private async getCoordinates(address: string): Promise<{ lat: number; lng: number }> {
160
+ const apiKey = this.getGoogleMapsApiKey();
161
+ const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${apiKey}`;
162
+
163
+ interface GeocodingResponse {
164
+ results: { geometry: { location: { lat: number; lng: number } } }[];
165
+ status: string;
166
+ }
167
+ try {
168
+ const response = await firstValueFrom(
169
+ this.http.get<GeocodingResponse>(url).pipe(
170
+ map((data) => {
171
+ if (data.status === 'OK' && data.results.length > 0) {
172
+ return data.results[0].geometry.location;
173
+ } else {
174
+ throw new Error(`Geocoding failed: ${data.status}`);
175
+ }
176
+ }),
177
+ ),
178
+ );
179
+ return response;
180
+ } catch (error) {
181
+ throw new Error(`Geocoding failed: ${error}`);
182
+ }
183
+ }
184
+ }
@@ -0,0 +1,13 @@
1
+ <div class="social-login">
2
+ @if (googleEnabled) {
3
+ <div #googleBtn class="social-google"></div>
4
+ }
5
+ @if (appleEnabled) {
6
+ <button type="button" class="social-apple-btn" (click)="onAppleClick()" [disabled]="loading()">
7
+ Sign in with Apple
8
+ </button>
9
+ }
10
+ @if (errorMessage()) {
11
+ <div class="auth-error" role="alert">{{ errorMessage() }}</div>
12
+ }
13
+ </div>
@@ -0,0 +1,120 @@
1
+ import {
2
+ ChangeDetectionStrategy, Component, ElementRef, OnInit, ViewChild, ViewEncapsulation,
3
+ inject, input, output,
4
+ } from '@angular/core';
5
+ import { BaseAsync } from '../abstract/base_async';
6
+ import { BaseAuthService } from '../../service/auth.service';
7
+ import { loadScript } from '../../service/script_loader';
8
+ import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
9
+ import {
10
+ ConsentState, LoginResponseSocial, SignupConsent, SocialProvider,
11
+ } from '../../model/auth';
12
+
13
+ declare const google: any;
14
+ declare const AppleID: any;
15
+
16
+ const GSI_URL = 'https://accounts.google.com/gsi/client';
17
+ const APPLE_URL = 'https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js';
18
+
19
+ /**
20
+ * SocialLoginComponent — renders "Sign in with Google" / "Sign in with Apple"
21
+ * buttons using each provider's official SDK.
22
+ *
23
+ * Flow: click → SDK returns ID token → BaseAuthService.loginSocial(provider,
24
+ * idToken, signupConsent) → emits loginSuccess / loginError.
25
+ *
26
+ * Requires `googleClientId` / `appleServiceId` in SailGuiConfig; providers
27
+ * without config are hidden (with a console.warn for diagnostics).
28
+ */
29
+ @Component({
30
+ selector: 'app-social-login',
31
+ templateUrl: './social_login.html',
32
+ changeDetection: ChangeDetectionStrategy.OnPush,
33
+ encapsulation: ViewEncapsulation.None,
34
+ imports: [],
35
+ })
36
+ export class SocialLoginComponent extends BaseAsync implements OnInit {
37
+ private readonly auth = inject(BaseAuthService);
38
+ protected readonly guiConfig: SailGuiConfig =
39
+ inject(SAIL_GUI_CONFIG, { optional: true }) ?? DEFAULT_CONFIG;
40
+
41
+ readonly providers = input<SocialProvider[]>(['google']);
42
+ readonly consent = input<ConsentState | undefined>(undefined);
43
+
44
+ readonly loginSuccess = output<LoginResponseSocial>();
45
+ readonly loginError = output<Error>();
46
+
47
+ @ViewChild('googleBtn', { static: false }) googleBtn?: ElementRef<HTMLDivElement>;
48
+
49
+ protected googleEnabled = false;
50
+ protected appleEnabled = false;
51
+
52
+ ngOnInit() {
53
+ this.googleEnabled = this.providers().includes('google') && !!this.guiConfig.googleClientId;
54
+ this.appleEnabled = this.providers().includes('apple') && !!this.guiConfig.appleServiceId;
55
+
56
+ if (this.providers().includes('google') && !this.guiConfig.googleClientId) {
57
+ console.warn('SocialLoginComponent: googleClientId missing from SailGuiConfig — Google button hidden');
58
+ }
59
+ if (this.providers().includes('apple') && !this.guiConfig.appleServiceId) {
60
+ console.warn('SocialLoginComponent: appleServiceId missing from SailGuiConfig — Apple button hidden');
61
+ }
62
+
63
+ if (this.googleEnabled) this.initGoogle();
64
+ if (this.appleEnabled) this.initApple();
65
+ }
66
+
67
+ private async initGoogle() {
68
+ await loadScript(GSI_URL);
69
+ google.accounts.id.initialize({
70
+ client_id: this.guiConfig.googleClientId,
71
+ callback: (res: { credential: string }) => this.onIdToken('google', res.credential),
72
+ });
73
+ // Render into the template anchor once the view is ready.
74
+ queueMicrotask(() => {
75
+ if (this.googleBtn?.nativeElement) {
76
+ google.accounts.id.renderButton(this.googleBtn.nativeElement, { theme: 'outline', size: 'large' });
77
+ }
78
+ });
79
+ }
80
+
81
+ private async initApple() {
82
+ await loadScript(APPLE_URL);
83
+ AppleID.auth.init({
84
+ clientId: this.guiConfig.appleServiceId,
85
+ scope: 'name email',
86
+ redirectURI: this.guiConfig.appleRedirectUri ?? window.location.origin,
87
+ usePopup: true,
88
+ });
89
+ }
90
+
91
+ async onAppleClick() {
92
+ try {
93
+ const res = await AppleID.auth.signIn();
94
+ const token = res?.authorization?.id_token;
95
+ if (token) this.onIdToken('apple', token);
96
+ } catch (err: any) {
97
+ this.loginError.emit(err instanceof Error ? err : new Error(String(err)));
98
+ }
99
+ }
100
+
101
+ private onIdToken(provider: SocialProvider, idToken: string) {
102
+ this.run(
103
+ this.auth.loginSocial(provider, idToken, this.buildSignupConsent()),
104
+ (resp) => this.loginSuccess.emit(resp),
105
+ `Sign-in with ${provider} failed.`,
106
+ );
107
+ // Also surface errors to the host via output.
108
+ // (BaseAsync.run already captures errorMessage; the emit is additional.)
109
+ }
110
+
111
+ private buildSignupConsent(): SignupConsent | undefined {
112
+ const c = this.consent();
113
+ if (!c) return undefined;
114
+ return {
115
+ policyVersion: c.policyVersion,
116
+ policyLanguage: c.policyLanguage,
117
+ consents: c.consents,
118
+ };
119
+ }
120
+ }
@@ -0,0 +1,49 @@
1
+ <mat-sidenav-container class="sidenav-container">
2
+ <mat-sidenav #sidenav class="sidenav"
3
+ [attr.role]="isHandset() ? 'dialog' : 'navigation'"
4
+ [mode]="isHandset() ? 'over' : 'side'"
5
+ [opened]="isLoggedIn() && !isHandset()">
6
+ <mat-toolbar class="primary">
7
+ <span>Menu</span>
8
+ </mat-toolbar>
9
+ @if (isLoggedIn()) {
10
+ <mat-accordion>
11
+ @for (menu of menuItems(); track menu.Id) {
12
+ <mat-expansion-panel>
13
+ <mat-expansion-panel-header>
14
+ <mat-panel-title>@if (menu.Icon) {<mat-icon>{{menu.Icon}}</mat-icon>}{{menu.Caption}}</mat-panel-title>
15
+ </mat-expansion-panel-header>
16
+ <mat-nav-list>
17
+ @for (item of menu.ApplicationMenuItems; track item.ItemId) {
18
+ <a mat-list-item [routerLink]="getRouterLink(item)">
19
+ @if (item.Icon) {<mat-icon>{{item.Icon}}</mat-icon>}
20
+ <span>{{item.Caption}}</span>
21
+ </a>
22
+ }
23
+ </mat-nav-list>
24
+ </mat-expansion-panel>
25
+ }
26
+ </mat-accordion>
27
+ }
28
+ </mat-sidenav>
29
+ <mat-sidenav-content>
30
+ <mat-toolbar class="primary">
31
+ @if (isHandset()) {
32
+ <button type="button" aria-label="Toggle sidenav" mat-icon-button (click)="sidenav.toggle()">
33
+ <mat-icon aria-label="Side nav toggle icon">menu</mat-icon>
34
+ </button>
35
+ }
36
+ <ng-content select="[toolbar-title]"></ng-content>
37
+ <span class="toolbar-spacer"></span>
38
+ <ng-content select="[toolbar-actions]"></ng-content>
39
+ @if (isLoggedIn()) {
40
+ <button mat-button (click)="logout()">Logout</button>
41
+ } @else if (guiConfig.publicRouteLinks) {
42
+ @for (link of guiConfig.publicRouteLinks; track link.routerLink) {
43
+ <a mat-button [routerLink]="link.routerLink">{{ link.label }}</a>
44
+ }
45
+ }
46
+ </mat-toolbar>
47
+ <router-outlet></router-outlet>
48
+ </mat-sidenav-content>
49
+ </mat-sidenav-container>
@@ -0,0 +1,69 @@
1
+ import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal, ViewEncapsulation } from "@angular/core";
2
+ import { toSignal } from "@angular/core/rxjs-interop";
3
+ import { RouterLink, RouterOutlet } from "@angular/router";
4
+ import { MatSidenavModule } from '@angular/material/sidenav';
5
+ import { MatToolbarModule } from '@angular/material/toolbar';
6
+ import { MatListModule } from '@angular/material/list';
7
+ import { MatIconModule } from '@angular/material/icon';
8
+ import { MatButtonModule } from '@angular/material/button';
9
+ import { MatExpansionModule } from '@angular/material/expansion';
10
+ import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
11
+ import { map } from 'rxjs/operators';
12
+ import { ApplicationMenu, ApplicationMenuItem } from "../../model/common";
13
+ import { BaseAuthService } from "../../service/auth.service";
14
+ import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from "../../config";
15
+
16
+ @Component({
17
+ selector: 'app-navigation',
18
+ templateUrl: './navigation.html',
19
+ changeDetection: ChangeDetectionStrategy.OnPush,
20
+ encapsulation: ViewEncapsulation.None,
21
+ imports: [
22
+ MatSidenavModule,
23
+ MatToolbarModule,
24
+ MatListModule,
25
+ MatIconModule,
26
+ MatButtonModule,
27
+ RouterOutlet,
28
+ RouterLink,
29
+ MatExpansionModule,
30
+ ],
31
+ })
32
+ export class Navigation implements OnInit {
33
+ private readonly authService = inject(BaseAuthService);
34
+ private readonly breakpointObserver = inject(BreakpointObserver);
35
+ protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
36
+
37
+ private readonly allMenuItems = signal<ApplicationMenu[]>([]);
38
+
39
+ readonly menuItems = computed(() => {
40
+ const menus = this.allMenuItems();
41
+ return menus
42
+ .map(menu => ({
43
+ ...menu,
44
+ ApplicationMenuItems: (menu.ApplicationMenuItems ?? [])
45
+ .filter(item => item.ItemId && this.authService.canAccess(item.ItemId)),
46
+ }))
47
+ .filter(menu => menu.ApplicationMenuItems.length > 0);
48
+ });
49
+
50
+ readonly isHandset = toSignal(
51
+ this.breakpointObserver.observe(Breakpoints.Handset).pipe(map((result) => result.matches)),
52
+ { initialValue: false },
53
+ );
54
+
55
+ readonly isLoggedIn = this.authService.isLoggedIn;
56
+
57
+ ngOnInit() {
58
+ this.authService.loadStoredSession();
59
+ this.authService.getMenus().subscribe((menuItems: ApplicationMenu[]) => { this.allMenuItems.set(menuItems); });
60
+ }
61
+
62
+ logout() {
63
+ this.authService.logout();
64
+ }
65
+
66
+ getRouterLink(item: ApplicationMenuItem): string {
67
+ return '/' + (item.MenuId ?? '').toLowerCase() + '/' + (item.ItemId ?? '');
68
+ }
69
+ }