@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.
- package/LICENSE +201 -0
- package/README.md +741 -0
- package/package.json +58 -0
- package/src/component/abstract/base_async.ts +49 -0
- package/src/component/abstract/base_form.ts +11 -0
- package/src/component/abstract/base_table.ts +320 -0
- package/src/component/abstract/base_view.ts +93 -0
- package/src/component/billing/checkout_button.html +10 -0
- package/src/component/billing/checkout_button.ts +57 -0
- package/src/component/billing/payment_methods.html +27 -0
- package/src/component/billing/payment_methods.ts +36 -0
- package/src/component/billing/plan_selector.html +24 -0
- package/src/component/billing/plan_selector.ts +29 -0
- package/src/component/form/form_field.html +54 -0
- package/src/component/form/form_field.ts +156 -0
- package/src/component/form/form_record.html +14 -0
- package/src/component/form/form_record.ts +56 -0
- package/src/component/form/form_table.html +19 -0
- package/src/component/form/form_table.ts +58 -0
- package/src/component/login/account_deletion.html +42 -0
- package/src/component/login/account_deletion.ts +79 -0
- package/src/component/login/chpass_component.html +58 -0
- package/src/component/login/chpass_component.ts +96 -0
- package/src/component/login/confirm_chpass_component.html +47 -0
- package/src/component/login/confirm_chpass_component.ts +70 -0
- package/src/component/login/confirm_register_component.html +37 -0
- package/src/component/login/confirm_register_component.ts +71 -0
- package/src/component/login/consent_gate.html +30 -0
- package/src/component/login/consent_gate.ts +109 -0
- package/src/component/login/login_component.html +43 -0
- package/src/component/login/login_component.ts +73 -0
- package/src/component/login/otp_input.html +38 -0
- package/src/component/login/otp_input.ts +104 -0
- package/src/component/login/register_component.html +132 -0
- package/src/component/login/register_component.ts +184 -0
- package/src/component/login/social_login.html +13 -0
- package/src/component/login/social_login.ts +120 -0
- package/src/component/navigation/navigation.html +49 -0
- package/src/component/navigation/navigation.ts +69 -0
- package/src/component/security/trusted_devices.html +76 -0
- package/src/component/security/trusted_devices.ts +92 -0
- package/src/component/security/twofactor_setup.html +75 -0
- package/src/component/security/twofactor_setup.ts +106 -0
- package/src/component/security/twofactor_verify.html +45 -0
- package/src/component/security/twofactor_verify.ts +90 -0
- package/src/component/table/table_detail.html +53 -0
- package/src/component/table/table_detail.ts +133 -0
- package/src/component/table/table_edit.html +58 -0
- package/src/component/table/table_edit.ts +163 -0
- package/src/component/table/table_list.html +41 -0
- package/src/component/table/table_list.ts +100 -0
- package/src/component/table/table_lookup.html +35 -0
- package/src/component/table/table_lookup.ts +88 -0
- package/src/component/table/table_search.html +17 -0
- package/src/component/table/table_search.ts +80 -0
- package/src/config.ts +67 -0
- package/src/index.ts +41 -0
- package/src/model/appdata.ts +266 -0
- package/src/model/auth.ts +134 -0
- package/src/model/common.ts +115 -0
- package/src/model/decorator.ts +74 -0
- package/src/service/api-response.interceptor.ts +26 -0
- package/src/service/auth.interceptor.ts +11 -0
- package/src/service/auth.service.ts +576 -0
- package/src/service/billing.service.ts +65 -0
- package/src/service/label.service.ts +18 -0
- package/src/service/rest_service.ts +94 -0
- package/src/service/rest_url.ts +42 -0
- package/src/service/script_loader.ts +31 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, inject, signal, ViewEncapsulation } from '@angular/core';
|
|
2
|
+
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
3
|
+
import { BaseAuthService } from '../../service/auth.service';
|
|
4
|
+
import { Router, RouterLink } from '@angular/router';
|
|
5
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
6
|
+
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
7
|
+
import { MatInputModule } from '@angular/material/input';
|
|
8
|
+
import { MatCheckboxModule } from '@angular/material/checkbox';
|
|
9
|
+
import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
|
|
10
|
+
|
|
11
|
+
@Component({
|
|
12
|
+
selector: 'app-chpass',
|
|
13
|
+
templateUrl: './chpass_component.html',
|
|
14
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
15
|
+
encapsulation: ViewEncapsulation.None,
|
|
16
|
+
imports: [
|
|
17
|
+
ReactiveFormsModule,
|
|
18
|
+
MatButtonModule,
|
|
19
|
+
MatFormFieldModule,
|
|
20
|
+
MatInputModule,
|
|
21
|
+
MatCheckboxModule,
|
|
22
|
+
RouterLink,
|
|
23
|
+
],
|
|
24
|
+
})
|
|
25
|
+
export class ChpassComponent {
|
|
26
|
+
private auth = inject(BaseAuthService);
|
|
27
|
+
private fb = inject(FormBuilder);
|
|
28
|
+
private router = inject(Router);
|
|
29
|
+
protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
|
|
30
|
+
|
|
31
|
+
forgotPassword = signal(false);
|
|
32
|
+
errorMessage = signal('');
|
|
33
|
+
successMessage = signal('');
|
|
34
|
+
|
|
35
|
+
chpassForm = this.fb.group({
|
|
36
|
+
username: ['', Validators.required],
|
|
37
|
+
old_password: ['', Validators.required],
|
|
38
|
+
new_password: ['', [Validators.required, Validators.minLength(6)]],
|
|
39
|
+
confirm_password: ['', Validators.required],
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
toggleForgotPassword(checked: boolean) {
|
|
43
|
+
this.forgotPassword.set(checked);
|
|
44
|
+
const oldPw = this.chpassForm.get('old_password')!;
|
|
45
|
+
const newPw = this.chpassForm.get('new_password')!;
|
|
46
|
+
const confirmPw = this.chpassForm.get('confirm_password')!;
|
|
47
|
+
if (checked) {
|
|
48
|
+
oldPw.clearValidators();
|
|
49
|
+
oldPw.setValue('');
|
|
50
|
+
newPw.clearValidators();
|
|
51
|
+
newPw.setValue('');
|
|
52
|
+
confirmPw.clearValidators();
|
|
53
|
+
confirmPw.setValue('');
|
|
54
|
+
} else {
|
|
55
|
+
oldPw.setValidators(Validators.required);
|
|
56
|
+
newPw.setValidators([Validators.required, Validators.minLength(6)]);
|
|
57
|
+
confirmPw.setValidators(Validators.required);
|
|
58
|
+
}
|
|
59
|
+
oldPw.updateValueAndValidity();
|
|
60
|
+
newPw.updateValueAndValidity();
|
|
61
|
+
confirmPw.updateValueAndValidity();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
changePassword(): void {
|
|
65
|
+
this.errorMessage.set('');
|
|
66
|
+
this.successMessage.set('');
|
|
67
|
+
|
|
68
|
+
if (this.chpassForm.invalid) return;
|
|
69
|
+
|
|
70
|
+
const { username, old_password, new_password, confirm_password } = this.chpassForm.value;
|
|
71
|
+
|
|
72
|
+
if (new_password !== confirm_password) {
|
|
73
|
+
this.errorMessage.set('Passwords do not match.');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const request = this.forgotPassword()
|
|
78
|
+
? this.auth.forgotPassword(username!)
|
|
79
|
+
: this.auth.chpass(username!, new_password!, old_password!);
|
|
80
|
+
|
|
81
|
+
request.subscribe({
|
|
82
|
+
next: () => {
|
|
83
|
+
if (this.forgotPassword()) {
|
|
84
|
+
this.successMessage.set('A confirmation code has been sent to your email.');
|
|
85
|
+
this.router.navigate(['/confirm/password'], { queryParams: { username } });
|
|
86
|
+
} else {
|
|
87
|
+
this.successMessage.set('Password changed successfully.');
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
error: (err) => {
|
|
91
|
+
const msg = typeof err.error === 'string' ? err.error : err.error?.message;
|
|
92
|
+
this.errorMessage.set(msg || 'Password change failed. Please try again.');
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
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">
|
|
8
|
+
<h2 class="auth-title">Confirm Password Change</h2>
|
|
9
|
+
<p class="auth-instructions">Enter the confirmation code sent to your email and your new password.</p>
|
|
10
|
+
<form [formGroup]="confirmForm" (ngSubmit)="confirm()" class="auth-form">
|
|
11
|
+
<mat-form-field appearance="outline">
|
|
12
|
+
<mat-label>Username</mat-label>
|
|
13
|
+
<input matInput type="text" formControlName="username" required>
|
|
14
|
+
</mat-form-field>
|
|
15
|
+
|
|
16
|
+
<mat-form-field appearance="outline">
|
|
17
|
+
<mat-label>Confirmation Code</mat-label>
|
|
18
|
+
<input matInput type="text" formControlName="code" required>
|
|
19
|
+
</mat-form-field>
|
|
20
|
+
|
|
21
|
+
<mat-form-field appearance="outline">
|
|
22
|
+
<mat-label>New Password</mat-label>
|
|
23
|
+
<input matInput type="password" formControlName="new_password" required>
|
|
24
|
+
</mat-form-field>
|
|
25
|
+
|
|
26
|
+
<mat-form-field appearance="outline">
|
|
27
|
+
<mat-label>Confirm New Password</mat-label>
|
|
28
|
+
<input matInput type="password" formControlName="confirm_password" required>
|
|
29
|
+
</mat-form-field>
|
|
30
|
+
|
|
31
|
+
@if (errorMessage()) {
|
|
32
|
+
<div class="auth-error" role="alert">{{ errorMessage() }}</div>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@if (successMessage()) {
|
|
36
|
+
<div class="auth-success" role="status">{{ successMessage() }}</div>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
<button mat-raised-button class="primary" type="submit" [disabled]="!confirmForm.valid">
|
|
40
|
+
Confirm Password Change
|
|
41
|
+
</button>
|
|
42
|
+
</form>
|
|
43
|
+
<div class="auth-actions">
|
|
44
|
+
<a routerLink="/login/local">Back to Login</a>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, inject, signal, OnInit, ViewEncapsulation } from '@angular/core';
|
|
2
|
+
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
3
|
+
import { BaseAuthService } from '../../service/auth.service';
|
|
4
|
+
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
|
5
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
6
|
+
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
7
|
+
import { MatInputModule } from '@angular/material/input';
|
|
8
|
+
import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
|
|
9
|
+
|
|
10
|
+
@Component({
|
|
11
|
+
selector: 'app-confirm-chpass',
|
|
12
|
+
templateUrl: './confirm_chpass_component.html',
|
|
13
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
14
|
+
encapsulation: ViewEncapsulation.None,
|
|
15
|
+
imports: [
|
|
16
|
+
ReactiveFormsModule,
|
|
17
|
+
MatButtonModule,
|
|
18
|
+
MatFormFieldModule,
|
|
19
|
+
MatInputModule,
|
|
20
|
+
RouterLink,
|
|
21
|
+
],
|
|
22
|
+
})
|
|
23
|
+
export class ConfirmChpassComponent implements OnInit {
|
|
24
|
+
private auth = inject(BaseAuthService);
|
|
25
|
+
private fb = inject(FormBuilder);
|
|
26
|
+
private router = inject(Router);
|
|
27
|
+
private route = inject(ActivatedRoute);
|
|
28
|
+
protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
|
|
29
|
+
|
|
30
|
+
errorMessage = signal('');
|
|
31
|
+
successMessage = signal('');
|
|
32
|
+
|
|
33
|
+
confirmForm = this.fb.group({
|
|
34
|
+
username: ['', Validators.required],
|
|
35
|
+
code: ['', Validators.required],
|
|
36
|
+
new_password: ['', [Validators.required, Validators.minLength(6)]],
|
|
37
|
+
confirm_password: ['', Validators.required],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
ngOnInit() {
|
|
41
|
+
const username = this.route.snapshot.queryParamMap.get('username');
|
|
42
|
+
if (username) {
|
|
43
|
+
this.confirmForm.patchValue({ username });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
confirm(): void {
|
|
48
|
+
this.errorMessage.set('');
|
|
49
|
+
this.successMessage.set('');
|
|
50
|
+
|
|
51
|
+
if (this.confirmForm.invalid) return;
|
|
52
|
+
|
|
53
|
+
const { username, code, new_password, confirm_password } = this.confirmForm.value;
|
|
54
|
+
|
|
55
|
+
if (new_password !== confirm_password) {
|
|
56
|
+
this.errorMessage.set('Passwords do not match.');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
this.auth.confirmChpass(username!, code!, new_password!).subscribe({
|
|
61
|
+
next: () => {
|
|
62
|
+
this.successMessage.set('Password changed successfully. Redirecting to login...');
|
|
63
|
+
this.router.navigate(['/login/local']);
|
|
64
|
+
},
|
|
65
|
+
error: (err) => {
|
|
66
|
+
this.errorMessage.set(err.error?.message ?? 'Confirmation failed. Please try again.');
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
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">
|
|
8
|
+
<h2 class="auth-title">Confirm Registration</h2>
|
|
9
|
+
<p class="auth-instructions">Enter the confirmation code sent to your email address.</p>
|
|
10
|
+
<form [formGroup]="confirmForm" (ngSubmit)="confirm()" class="auth-form">
|
|
11
|
+
<mat-form-field appearance="outline">
|
|
12
|
+
<mat-label>Email</mat-label>
|
|
13
|
+
<input matInput type="email" formControlName="email" required>
|
|
14
|
+
</mat-form-field>
|
|
15
|
+
|
|
16
|
+
<mat-form-field appearance="outline">
|
|
17
|
+
<mat-label>Confirmation Code</mat-label>
|
|
18
|
+
<input matInput type="text" formControlName="code" required>
|
|
19
|
+
</mat-form-field>
|
|
20
|
+
|
|
21
|
+
@if (errorMessage()) {
|
|
22
|
+
<div class="auth-error" role="alert">{{ errorMessage() }}</div>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@if (successMessage()) {
|
|
26
|
+
<div class="auth-success" role="status">{{ successMessage() }}</div>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
<button mat-raised-button class="primary" type="submit" [disabled]="!confirmForm.valid">
|
|
30
|
+
Confirm
|
|
31
|
+
</button>
|
|
32
|
+
</form>
|
|
33
|
+
<div class="auth-actions">
|
|
34
|
+
<a routerLink="/login/local">Back to Login</a>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, inject, signal, OnInit, ViewEncapsulation } from '@angular/core';
|
|
2
|
+
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
3
|
+
import { BaseAuthService } from '../../service/auth.service';
|
|
4
|
+
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
|
5
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
6
|
+
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
7
|
+
import { MatInputModule } from '@angular/material/input';
|
|
8
|
+
import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
|
|
9
|
+
|
|
10
|
+
@Component({
|
|
11
|
+
selector: 'app-confirm-register',
|
|
12
|
+
templateUrl: './confirm_register_component.html',
|
|
13
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
14
|
+
encapsulation: ViewEncapsulation.None,
|
|
15
|
+
imports: [
|
|
16
|
+
ReactiveFormsModule,
|
|
17
|
+
MatButtonModule,
|
|
18
|
+
MatFormFieldModule,
|
|
19
|
+
MatInputModule,
|
|
20
|
+
RouterLink,
|
|
21
|
+
],
|
|
22
|
+
})
|
|
23
|
+
export class ConfirmRegisterComponent implements OnInit {
|
|
24
|
+
private auth = inject(BaseAuthService);
|
|
25
|
+
private fb = inject(FormBuilder);
|
|
26
|
+
private router = inject(Router);
|
|
27
|
+
private route = inject(ActivatedRoute);
|
|
28
|
+
protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
|
|
29
|
+
|
|
30
|
+
errorMessage = signal('');
|
|
31
|
+
successMessage = signal('');
|
|
32
|
+
|
|
33
|
+
confirmForm = this.fb.group({
|
|
34
|
+
email: ['', [Validators.required, Validators.email]],
|
|
35
|
+
code: ['', Validators.required],
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
ngOnInit() {
|
|
39
|
+
const email = this.route.snapshot.queryParamMap.get('email');
|
|
40
|
+
if (email) {
|
|
41
|
+
this.confirmForm.patchValue({ email });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
confirm(): void {
|
|
46
|
+
this.errorMessage.set('');
|
|
47
|
+
this.successMessage.set('');
|
|
48
|
+
|
|
49
|
+
if (this.confirmForm.invalid) return;
|
|
50
|
+
|
|
51
|
+
const { email, code } = this.confirmForm.value;
|
|
52
|
+
|
|
53
|
+
this.auth.confirmRegister(email!, code!).subscribe({
|
|
54
|
+
next: (resp) => {
|
|
55
|
+
this.successMessage.set('Registration confirmed.');
|
|
56
|
+
if (resp?.paymentRequired && resp.paymentUrl) {
|
|
57
|
+
// Paid plan: go straight to payment. Subscription is 'P' until paid.
|
|
58
|
+
// Using window.location (not router) so absolute / cross-origin
|
|
59
|
+
// payment URLs also work.
|
|
60
|
+
window.location.href = resp.paymentUrl;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// Free plan: land on login.
|
|
64
|
+
this.router.navigate(['/login/local']);
|
|
65
|
+
},
|
|
66
|
+
error: (err) => {
|
|
67
|
+
this.errorMessage.set(err.error?.message ?? 'Confirmation failed. Please try again.');
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<form [formGroup]="form" class="consent-form" role="group" aria-label="Consent required to continue">
|
|
2
|
+
<label class="consent-row">
|
|
3
|
+
<mat-checkbox [formControlName]="ConsentType.PRIVACY_POLICY" required>
|
|
4
|
+
I have read and agree to the
|
|
5
|
+
<a [attr.href]="effectivePrivacyUrl()" target="_blank" rel="noopener noreferrer">Privacy Policy</a>
|
|
6
|
+
and Terms of Service.
|
|
7
|
+
</mat-checkbox>
|
|
8
|
+
</label>
|
|
9
|
+
|
|
10
|
+
<label class="consent-row">
|
|
11
|
+
<mat-checkbox [formControlName]="ConsentType.CROSS_BORDER" required>
|
|
12
|
+
I consent to my personal information being processed outside my region
|
|
13
|
+
as described in the Privacy Policy.
|
|
14
|
+
</mat-checkbox>
|
|
15
|
+
</label>
|
|
16
|
+
|
|
17
|
+
@for (opt of optionalConsents(); track opt.id) {
|
|
18
|
+
<label class="consent-row">
|
|
19
|
+
<mat-checkbox [formControlName]="opt.id" [required]="opt.required ?? false">
|
|
20
|
+
{{ opt.label }}
|
|
21
|
+
@if (opt.linkUrl && opt.linkText) {
|
|
22
|
+
<a [attr.href]="opt.linkUrl" target="_blank" rel="noopener noreferrer">{{ opt.linkText }}</a>
|
|
23
|
+
}
|
|
24
|
+
@if (opt.hint) {
|
|
25
|
+
<small class="consent-hint">{{ opt.hint }}</small>
|
|
26
|
+
}
|
|
27
|
+
</mat-checkbox>
|
|
28
|
+
</label>
|
|
29
|
+
}
|
|
30
|
+
</form>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy, Component, DestroyRef, ViewEncapsulation,
|
|
3
|
+
computed, effect, inject, input, output, signal,
|
|
4
|
+
} from '@angular/core';
|
|
5
|
+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
6
|
+
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
7
|
+
import { MatCheckboxModule } from '@angular/material/checkbox';
|
|
8
|
+
import { ConsentOption, ConsentState, ConsentType } from '../../model/auth';
|
|
9
|
+
import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* ConsentGate — generic signup / privacy-settings checkboxes.
|
|
13
|
+
*
|
|
14
|
+
* Always renders two required consents:
|
|
15
|
+
* - privacy_policy (links to config.privacyPolicyUrl)
|
|
16
|
+
* - cross_border (cross-border data processing acknowledgement)
|
|
17
|
+
*
|
|
18
|
+
* Additional checkboxes are declared by the consumer via `optionalConsents`.
|
|
19
|
+
* Emits `consentStateChange` on every change with a `Record<string, boolean>`
|
|
20
|
+
* map keyed by ConsentType values — mirrors keel's user.SignupConsent.Consents.
|
|
21
|
+
*
|
|
22
|
+
* See keel/README.md → "Consent Capture" for the backend-side contract.
|
|
23
|
+
*/
|
|
24
|
+
@Component({
|
|
25
|
+
selector: 'app-consent-gate',
|
|
26
|
+
templateUrl: './consent_gate.html',
|
|
27
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
28
|
+
encapsulation: ViewEncapsulation.None,
|
|
29
|
+
imports: [ReactiveFormsModule, MatCheckboxModule],
|
|
30
|
+
})
|
|
31
|
+
export class ConsentGateComponent {
|
|
32
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
33
|
+
protected readonly guiConfig: SailGuiConfig =
|
|
34
|
+
inject(SAIL_GUI_CONFIG, { optional: true }) ?? DEFAULT_CONFIG;
|
|
35
|
+
|
|
36
|
+
/** Content hash of the currently-deployed policy. Required. */
|
|
37
|
+
readonly policyVersion = input<string>();
|
|
38
|
+
/** ISO 639-1 policy language code. */
|
|
39
|
+
readonly policyLanguage = input<string>('en');
|
|
40
|
+
/** ISO country code of the policy variant. */
|
|
41
|
+
readonly policyRegion = input<string | undefined>(undefined);
|
|
42
|
+
/** Public URL of the privacy policy text. Falls back to config.privacyPolicyUrl. */
|
|
43
|
+
readonly privacyUrl = input<string | undefined>(undefined);
|
|
44
|
+
/** Optional extra checkboxes (e.g. video_opt_in, marketing). */
|
|
45
|
+
readonly optionalConsents = input<ConsentOption[]>([]);
|
|
46
|
+
|
|
47
|
+
readonly consentStateChange = output<ConsentState>();
|
|
48
|
+
|
|
49
|
+
// Plain (untyped) FormGroup because optionalConsents add/remove controls dynamically;
|
|
50
|
+
// a strongly-typed group would reject those calls.
|
|
51
|
+
readonly form: FormGroup = new FormGroup({
|
|
52
|
+
[ConsentType.PRIVACY_POLICY]: new FormControl(false, { nonNullable: true, validators: Validators.requiredTrue }),
|
|
53
|
+
[ConsentType.CROSS_BORDER]: new FormControl(false, { nonNullable: true, validators: Validators.requiredTrue }),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
private readonly formValue = signal<Record<string, boolean>>({});
|
|
57
|
+
private readonly formValid = signal(false);
|
|
58
|
+
|
|
59
|
+
readonly effectivePolicyVersion = computed(() =>
|
|
60
|
+
this.policyVersion() ?? this.guiConfig.defaultPolicyVersion ?? '',
|
|
61
|
+
);
|
|
62
|
+
readonly effectivePolicyLanguage = computed(() =>
|
|
63
|
+
this.policyLanguage() ?? this.guiConfig.defaultPolicyLanguage ?? 'en',
|
|
64
|
+
);
|
|
65
|
+
readonly effectivePrivacyUrl = computed(() =>
|
|
66
|
+
this.privacyUrl() ?? this.guiConfig.privacyPolicyUrl ?? '/privacy-policy',
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
readonly state = computed<ConsentState>(() => ({
|
|
70
|
+
consents: this.formValue(),
|
|
71
|
+
policyVersion: this.effectivePolicyVersion(),
|
|
72
|
+
policyLanguage: this.effectivePolicyLanguage(),
|
|
73
|
+
valid: this.formValid(),
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
constructor() {
|
|
77
|
+
// Register optional consent controls dynamically when the input changes.
|
|
78
|
+
effect(() => {
|
|
79
|
+
const options = this.optionalConsents();
|
|
80
|
+
for (const opt of options) {
|
|
81
|
+
if (!this.form.contains(opt.id)) {
|
|
82
|
+
this.form.addControl(
|
|
83
|
+
opt.id,
|
|
84
|
+
new FormControl(false, { nonNullable: true, validators: opt.required ? Validators.requiredTrue : [] }),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Drop controls for options that were removed.
|
|
89
|
+
for (const key of Object.keys(this.form.controls)) {
|
|
90
|
+
if (key === ConsentType.PRIVACY_POLICY || key === ConsentType.CROSS_BORDER) continue;
|
|
91
|
+
if (!options.some((o) => o.id === key)) this.form.removeControl(key);
|
|
92
|
+
}
|
|
93
|
+
this.formValue.set(this.form.getRawValue());
|
|
94
|
+
this.formValid.set(this.form.valid);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
this.form.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
|
98
|
+
this.formValue.set(this.form.getRawValue());
|
|
99
|
+
this.formValid.set(this.form.valid);
|
|
100
|
+
});
|
|
101
|
+
this.form.statusChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
|
102
|
+
this.formValid.set(this.form.valid);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
effect(() => this.consentStateChange.emit(this.state()));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
protected readonly ConsentType = ConsentType;
|
|
109
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
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">
|
|
8
|
+
<h2 class="auth-title">Login</h2>
|
|
9
|
+
@if (loggedOut) {
|
|
10
|
+
<p class="auth-message" role="status" aria-live="polite">You have been logged out successfully.</p>
|
|
11
|
+
}
|
|
12
|
+
<form [formGroup]="loginForm" (ngSubmit)="login()" class="auth-form">
|
|
13
|
+
<mat-form-field appearance="outline">
|
|
14
|
+
<mat-label>Username</mat-label>
|
|
15
|
+
<input matInput type="text" formControlName="username" required>
|
|
16
|
+
</mat-form-field>
|
|
17
|
+
<mat-form-field appearance="outline">
|
|
18
|
+
<mat-label>Password</mat-label>
|
|
19
|
+
<input matInput type="password" formControlName="password" required>
|
|
20
|
+
</mat-form-field>
|
|
21
|
+
<button mat-raised-button class="primary" type="submit" [disabled]="!loginForm.valid">
|
|
22
|
+
Login
|
|
23
|
+
</button>
|
|
24
|
+
</form>
|
|
25
|
+
@if (showSocialLogin()) {
|
|
26
|
+
<hr class="auth-divider">
|
|
27
|
+
<app-social-login
|
|
28
|
+
[providers]="enabledSocialProviders()"
|
|
29
|
+
(loginSuccess)="onSocialSuccess($event)"
|
|
30
|
+
(loginError)="onSocialError($event)" />
|
|
31
|
+
}
|
|
32
|
+
<div class="auth-actions">
|
|
33
|
+
<a routerLink="/login/chpass">I forgot my password</a>
|
|
34
|
+
</div>
|
|
35
|
+
@if (guiConfig.loginFooterLinks && guiConfig.loginFooterLinks.length > 0) {
|
|
36
|
+
<div class="auth-footer">
|
|
37
|
+
@for (link of guiConfig.loginFooterLinks; track link.routerLink) {
|
|
38
|
+
<a [routerLink]="link.routerLink">{{ link.label }}</a>
|
|
39
|
+
}
|
|
40
|
+
</div>
|
|
41
|
+
}
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, inject, ViewEncapsulation } from '@angular/core';
|
|
2
|
+
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
3
|
+
import { BaseAuthService } from '../../service/auth.service';
|
|
4
|
+
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
|
5
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
6
|
+
import { MatFormFieldModule } from "@angular/material/form-field";
|
|
7
|
+
import { MatInputModule } from '@angular/material/input';
|
|
8
|
+
import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
|
|
9
|
+
import { LoginResponseSocial, SocialProvider } from '../../model/auth';
|
|
10
|
+
import { SocialLoginComponent } from './social_login';
|
|
11
|
+
|
|
12
|
+
@Component({
|
|
13
|
+
selector: 'app-login',
|
|
14
|
+
templateUrl: './login_component.html',
|
|
15
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
16
|
+
encapsulation: ViewEncapsulation.None,
|
|
17
|
+
imports: [
|
|
18
|
+
ReactiveFormsModule,
|
|
19
|
+
MatButtonModule,
|
|
20
|
+
MatFormFieldModule,
|
|
21
|
+
MatInputModule,
|
|
22
|
+
RouterLink,
|
|
23
|
+
SocialLoginComponent,
|
|
24
|
+
],
|
|
25
|
+
})
|
|
26
|
+
export class LoginComponent {
|
|
27
|
+
private auth = inject(BaseAuthService);
|
|
28
|
+
private router = inject(Router);
|
|
29
|
+
protected fb = inject(FormBuilder);
|
|
30
|
+
protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
|
|
31
|
+
protected readonly loggedOut = inject(ActivatedRoute).snapshot.queryParamMap.get('loggedOut') === 'true';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Auto-show the embedded SocialLoginComponent whenever at least one
|
|
35
|
+
* provider is configured in SailGuiConfig. Consumers that don't set
|
|
36
|
+
* googleClientId/appleServiceId see no change; consumers that do get a
|
|
37
|
+
* working social-login surface inside the standard /login route — no
|
|
38
|
+
* wrapper component required.
|
|
39
|
+
*/
|
|
40
|
+
protected readonly enabledSocialProviders = computed<SocialProvider[]>(() => {
|
|
41
|
+
const providers: SocialProvider[] = [];
|
|
42
|
+
if (this.guiConfig.googleClientId) providers.push('google');
|
|
43
|
+
if (this.guiConfig.appleServiceId) providers.push('apple');
|
|
44
|
+
return providers;
|
|
45
|
+
});
|
|
46
|
+
protected readonly showSocialLogin = computed(() => this.enabledSocialProviders().length > 0);
|
|
47
|
+
|
|
48
|
+
loginForm = this.fb.group({
|
|
49
|
+
username: ['', Validators.required],
|
|
50
|
+
password: ['', Validators.required],
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
login(): void {
|
|
54
|
+
if (this.loginForm.valid) {
|
|
55
|
+
const { username, password } = this.loginForm.value;
|
|
56
|
+
this.auth.login(username!, password!);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* SocialLoginComponent emits the response after BaseAuthService.loginSocial
|
|
62
|
+
* persists the JWT internally. Mirror the same post-login navigation that
|
|
63
|
+
* password-login uses (root route by default; consumers override via
|
|
64
|
+
* BaseAuthService.navigateToRoleHome when role-aware routing lands).
|
|
65
|
+
*/
|
|
66
|
+
protected onSocialSuccess(_: LoginResponseSocial): void {
|
|
67
|
+
this.router.navigateByUrl('/');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
protected onSocialError(err: Error): void {
|
|
71
|
+
console.error('social login failed:', err);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<div class="otp-container">
|
|
2
|
+
@if (contact()) {
|
|
3
|
+
<p class="otp-sent-to">We sent a code to</p>
|
|
4
|
+
<p class="otp-contact">{{ contact() }}</p>
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
<div class="otp-digits" role="group" aria-label="Verification code">
|
|
8
|
+
@for (digit of digits(); track $index) {
|
|
9
|
+
<div class="otp-digit"
|
|
10
|
+
[class.active]="$index === currentIndex()"
|
|
11
|
+
[attr.aria-label]="'Digit ' + ($index + 1)">
|
|
12
|
+
{{ digit }}
|
|
13
|
+
</div>
|
|
14
|
+
}
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<p class="otp-resend">
|
|
18
|
+
@if (canResend()) {
|
|
19
|
+
<button mat-button class="resend-btn" type="button" (click)="onResend()">Resend code</button>
|
|
20
|
+
} @else {
|
|
21
|
+
Resend code in {{ countdown() }}s
|
|
22
|
+
}
|
|
23
|
+
</p>
|
|
24
|
+
|
|
25
|
+
<div class="otp-keypad" role="group" aria-label="Numeric keypad">
|
|
26
|
+
@for (key of keypadKeys; track $index) {
|
|
27
|
+
@if (key === '') {
|
|
28
|
+
<div class="keypad-spacer"></div>
|
|
29
|
+
} @else if (key === 'back') {
|
|
30
|
+
<button mat-button class="keypad-key" type="button" (click)="onBackspace()" aria-label="Backspace" [disabled]="disabled()">
|
|
31
|
+
<mat-icon>backspace</mat-icon>
|
|
32
|
+
</button>
|
|
33
|
+
} @else {
|
|
34
|
+
<button mat-button class="keypad-key" type="button" (click)="onKeyPress(key)" [disabled]="disabled()">{{ key }}</button>
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|