@nauticana/sail 1.1.30 → 1.1.31
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/README.md +43 -10
- package/package.json +1 -1
- package/src/component/abstract/base_table.ts +10 -2
- package/src/component/login/login_component.html +4 -0
- package/src/component/login/login_component.ts +2 -0
- package/src/component/sso/identity_provider_configure_dialog.ts +68 -0
- package/src/component/sso/sso_login.html +8 -0
- package/src/component/sso/sso_login.ts +32 -0
- package/src/component/sso/sso_return.html +25 -0
- package/src/component/sso/sso_return.ts +82 -0
- package/src/component/table/action_params_dialog.html +38 -32
- package/src/component/table/action_params_dialog.ts +35 -7
- package/src/component/verification/domain_verification.html +79 -10
- package/src/component/verification/domain_verification.ts +56 -11
- package/src/config.ts +6 -0
- package/src/index.ts +3 -0
- package/src/service/auth.service.ts +21 -8
- package/src/service/domain_verifier.ts +22 -4
- package/src/service/rest_url.ts +3 -0
- package/src/util/sso.ts +43 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A shared Angular component library for building CRUD-based admin frontends. Provides table management, form handling, navigation, authentication, two-factor authentication, and trusted device management — all driven by metadata from a [keel](https://github.com/nauticana/keel) Go backend.
|
|
4
4
|
|
|
5
|
-
> **Compatibility:** sail and keel are versioned in lock-step. The current line is **sail v1.1.x ↔ keel v1.2.x**; sail v1.1.30 adopts keel v1.2.99 (password routes; a password change signs out), sail v1.1.29 adopts keel v1.2.94 (signup: confirmation signs in, partner setup, sign-in hand-off code), sail v1.1.28 adopts keel v1.2.93 (absolute `return` for the OAuth hand-off), sail v1.1.18 adopts keel v1.2.58 (refresh-token rotation, `/public/logout`, setup intents, the realtime hub), the agency components in sail v1.1.14 require keel v1.2.41 (sail v1.1.9 needs keel v1.2.16 for the OAuth-connect layer). Earlier lines: **sail v0.5.x ↔ keel v0.5.x**, **sail v0.6.x / v0.7.x ↔ keel v0.7.x**, **sail v0.8.x ↔ keel v0.8.x**, **sail v0.9.x ↔ keel v0.9.x**. Newer sail releases extend the contract — older keel servers reject unknown endpoints with HTTP 404 / 400. The v0.8.x line additionally ships the `table_action` framework (per-table custom buttons surfaced in `TableList` / `TableSearch` / `TableEdit` / `TableDetail`); see the [Migrating to v0.7.0 §5 — TableAction](#migrating-to-v070--payout-user-payment-methods-table-actions) section for the seed shape (basis `table_action` + `authorization_object` + `authorization_object_action` rows) and the [keel/README Table Actions](https://github.com/nauticana/keel#table-actions) section for backend wiring via `handler.WrapTableAction`.
|
|
5
|
+
> **Compatibility:** sail and keel are versioned in lock-step. The current line is **sail v1.1.x ↔ keel v1.2.x**; sail v1.1.31 adopts keel v1.2.100 (tenant single sign-on, DNS and HTTP-file domain verification), sail v1.1.30 adopts keel v1.2.99 (password routes; a password change signs out), sail v1.1.29 adopts keel v1.2.94 (signup: confirmation signs in, partner setup, sign-in hand-off code), sail v1.1.28 adopts keel v1.2.93 (absolute `return` for the OAuth hand-off), sail v1.1.18 adopts keel v1.2.58 (refresh-token rotation, `/public/logout`, setup intents, the realtime hub), the agency components in sail v1.1.14 require keel v1.2.41 (sail v1.1.9 needs keel v1.2.16 for the OAuth-connect layer). Earlier lines: **sail v0.5.x ↔ keel v0.5.x**, **sail v0.6.x / v0.7.x ↔ keel v0.7.x**, **sail v0.8.x ↔ keel v0.8.x**, **sail v0.9.x ↔ keel v0.9.x**. Newer sail releases extend the contract — older keel servers reject unknown endpoints with HTTP 404 / 400. The v0.8.x line additionally ships the `table_action` framework (per-table custom buttons surfaced in `TableList` / `TableSearch` / `TableEdit` / `TableDetail`); see the [Migrating to v0.7.0 §5 — TableAction](#migrating-to-v070--payout-user-payment-methods-table-actions) section for the seed shape (basis `table_action` + `authorization_object` + `authorization_object_action` rows) and the [keel/README Table Actions](https://github.com/nauticana/keel#table-actions) section for backend wiring via `handler.WrapTableAction`.
|
|
6
6
|
|
|
7
7
|
**Recent additions:** `BaseAuthService.acceptToken(jwt)` — adopt an externally-minted JWT (registration / SSO-handoff flows) and run the full post-login sequence (store under the canonical `jwt` key, load appdata, init routes); apps must use this instead of writing `localStorage` directly. `BaseRestService.analytic<T>(endpoint, params?)` — GET a keel `analytic/<endpoint>` report and return its rows. `passwordPolicyValidator` + `BaseAuthService.ensurePasswordPolicy()` — validate passwords against keel's policy (via `SailGuiConfig.passwordPolicyUrl`); see Configuration reference.
|
|
8
8
|
|
|
@@ -18,7 +18,7 @@ A shared Angular component library for building CRUD-based admin frontends. Prov
|
|
|
18
18
|
| **Login** | `LoginComponent`, `RegisterComponent`, `ChpassComponent`, `ConfirmRegisterComponent`, `ConfirmChpassComponent` |
|
|
19
19
|
| **Security** | `TwoFactorSetupComponent`, `TwoFactorVerifyComponent`, `TrustedDevicesComponent`, `AccountDeletionComponent` |
|
|
20
20
|
| **Account** | `MyAccountComponent` (self-service hub), `ProfileEditorComponent` (name/locale immediate; email/phone verify-before-apply) |
|
|
21
|
-
| **Auth** | `ConsentGateComponent`, `OtpInputComponent`, `SocialLoginComponent` |
|
|
21
|
+
| **Auth** | `ConsentGateComponent`, `OtpInputComponent`, `SocialLoginComponent`, `SsoLoginComponent`, `SsoReturnComponent` |
|
|
22
22
|
| **Billing** | `PlanSelectorComponent`, `PriceSelectorComponent`, `CheckoutButtonComponent`, `PaymentMethodsComponent`, `PortalButtonComponent`, `UsageMeterComponent`, `StatusChipComponent`, `TrialBannerComponent`, `SeatSelectorComponent`, `DunningBannerComponent` |
|
|
23
23
|
| **Dashboard** | `DashboardShellComponent`, `DataCardComponent`, `StatTileComponent`, `LockedOverlayComponent`, `ActionCenterComponent`, `EntitySelectorComponent`, `VerificationFlowComponent` |
|
|
24
24
|
| **Payout** | `PayoutProviderOnboardingComponent`, `PayoutBankInfoFormComponent` |
|
|
@@ -236,6 +236,8 @@ sail follows a **metadata-driven** architecture. On login, the backend returns `
|
|
|
236
236
|
|
|
237
237
|
`BaseAuthService.initRoutes()` dynamically builds Angular routes from this metadata. Each menu item automatically gets a `TableSearch` or `TableList` route with the correct API endpoint and table metadata. Custom components can override specific menu items via `menuItemRouteOverrides` in the config.
|
|
238
238
|
|
|
239
|
+
Application controls can call `BaseAuthService.hasPermission(object, action, value)` for any backend grant. Table actions continue to use `canExecute()`.
|
|
240
|
+
|
|
239
241
|
## List pagination
|
|
240
242
|
|
|
241
243
|
keel REST list responses are paginated:
|
|
@@ -310,6 +312,8 @@ interface SailGuiConfig {
|
|
|
310
312
|
passwordPolicyUrl?: string; // Public route serving keel's password policy (see below)
|
|
311
313
|
oauthServerUrl?: string; // Base URL of keel's OAuth authorization server (see below)
|
|
312
314
|
allowedReturnHosts?: string[]; // Hosts a post-login ?return= may name
|
|
315
|
+
ssoLogin?: boolean; // Shows organization sign-in on <sail-login> (see Tenant single sign-on)
|
|
316
|
+
ssoErrorMessages?: Partial<Record<SsoErrorCode, string>>; // Rewords single sign-on errors
|
|
313
317
|
}
|
|
314
318
|
```
|
|
315
319
|
|
|
@@ -601,7 +605,7 @@ A `TableAction` with `kind: 'R'` (redirect — the `constant_value` code) makes
|
|
|
601
605
|
|
|
602
606
|
### Action parameters (v1.1.25)
|
|
603
607
|
|
|
604
|
-
An action with `parameters` (keel v1.2.75 `table_action_parameter`) opens a dialog before posting: one field per parameter, typed by `dataType` (`boolean` checkbox, numeric, `date`, `timestamp`, `file`, otherwise text), or a dropdown when `lookupTable` names a table in the client cache (a `foreign_key_lookup` dropdown table); a numeric `dataType` still posts the chosen key as a number. The dialog shows `confirmMessage` in place of the native prompt and posts `{...primaryKeyValues(record), ...values}`; empty optional values are left out. A `file` parameter turns the post into `multipart/form-data`, so a table-level `upload` action whose parameters are `file`, `document_type` (lookup `document_type`), `title`, … posts straight to keel's `DocumentHandler.Upload`. An action without parameters behaves as before.
|
|
608
|
+
An action with `parameters` (keel v1.2.75 `table_action_parameter`) opens a dialog before posting: one field per parameter, typed by `dataType` (`boolean` checkbox, numeric, `date`, `timestamp`, `file`, `secret` or `password` as a masked input posted as a string, otherwise text), or a dropdown when `lookupTable` names a table in the client cache (a `foreign_key_lookup` dropdown table); a numeric `dataType` still posts the chosen key as a number. The dialog shows `confirmMessage` in place of the native prompt and posts `{...primaryKeyValues(record), ...values}`; empty optional values are left out. A `file` parameter turns the post into `multipart/form-data`, so a table-level `upload` action whose parameters are `file`, `document_type` (lookup `document_type`), `title`, … posts straight to keel's `DocumentHandler.Upload`. An action without parameters behaves as before.
|
|
605
609
|
|
|
606
610
|
### Status chip (v1.0.1)
|
|
607
611
|
|
|
@@ -1006,6 +1010,29 @@ Under the hood, the component calls `BaseAuthService.loginSocial(provider, idTok
|
|
|
1006
1010
|
|
|
1007
1011
|
For backward compatibility, the older OAuth-code flow `BaseAuthService.loginWithGoogle(code)` (hits `/public/login/google`) is still supported; prefer `loginSocial` for new code.
|
|
1008
1012
|
|
|
1013
|
+
## Tenant single sign-on (v1.1.31)
|
|
1014
|
+
|
|
1015
|
+
Requires keel v1.2.100 with `SSOHandler` mounted. An organization signs its users in through its own OpenID Connect or SAML identity provider; keel owns the protocol and every security decision.
|
|
1016
|
+
|
|
1017
|
+
Opt in and add the return route:
|
|
1018
|
+
|
|
1019
|
+
```typescript
|
|
1020
|
+
{
|
|
1021
|
+
ssoLogin: true, // <sail-login> shows "Continue with your organization"
|
|
1022
|
+
publicRoutes: [
|
|
1023
|
+
{ path: 'login/sso', loadComponent: () => import('@nauticana/sail').then(m => m.SsoReturnComponent) },
|
|
1024
|
+
],
|
|
1025
|
+
}
|
|
1026
|
+
```
|
|
1027
|
+
|
|
1028
|
+
Pair it with keel's `SSOHandler.PublicBaseURL` (the API origin) and `SSOHandler.FrontendReturnURL` (`https://<app>/login/sso`).
|
|
1029
|
+
|
|
1030
|
+
- `<sail-sso-login>` navigates the browser to `RestURL.ssoStartURL` (`/public/sso/start?email=…`) on the API host. It is a top-level navigation, never XHR, because keel binds the sign-in to an HttpOnly cookie there.
|
|
1031
|
+
- `<sail-sso-return>` handles keel's answer. A `code` is removed from the address bar and exchanged once through `exchangeHandoff()`; the user lands on the dashboard. An `error` shows its message and links to `/login/local`. A test outcome (`test=passed`, or `test=failed&error=`) is shown with a link to the identity provider screen found in the menu metadata.
|
|
1032
|
+
- `SSO_ERROR_MESSAGES` holds the wording of every keel error code; an unknown code reads as `server_error`. Override entries through `SailGuiConfig.ssoErrorMessages`.
|
|
1033
|
+
|
|
1034
|
+
Administration uses the generic screens for `partner_identity_provider` and the SCIM tables. The `configure` dialog filters fields by protocol and OIDC client authentication, masks the client secret, uses metadata captions for code lists, and links the SAML service-provider metadata. `test` redirects through keel; generated provisioning tokens appear once in the reveal dialog.
|
|
1035
|
+
|
|
1009
1036
|
## Dashboard components
|
|
1010
1037
|
|
|
1011
1038
|
Horizontal building blocks for an entitlement-aware dashboard. All are presentational (signals in, events out) and ship no CSS — the app styles the documented class hooks and supplies the metric data, chart, and action logic.
|
|
@@ -1045,11 +1072,11 @@ unless the backend sends
|
|
|
1045
1072
|
`source.status` is `null` and a missing source is indistinguishable from an
|
|
1046
1073
|
empty result.
|
|
1047
1074
|
|
|
1048
|
-
## Domain verification (v1.1.22)
|
|
1075
|
+
## Domain verification (v1.1.22; DNS and HTTP file v1.1.31)
|
|
1049
1076
|
|
|
1050
|
-
`DomainVerificationComponent` (`sail-domain-verification`)
|
|
1051
|
-
|
|
1052
|
-
|
|
1077
|
+
`DomainVerificationComponent` (`sail-domain-verification`) proves ownership of a
|
|
1078
|
+
domain: the domain field, an email code (`EC`) with its resend cooldown and expiry, a
|
|
1079
|
+
DNS TXT record (`DT`) or an HTTP file (`HF`) with copy buttons, and the errors. Every app that verifies a domain renders
|
|
1053
1080
|
the same thing, so only the endpoints stay downstream.
|
|
1054
1081
|
|
|
1055
1082
|
The endpoints reach it through the `DOMAIN_VERIFIER` port — sail ships no
|
|
@@ -1057,11 +1084,15 @@ implementation and knows none of the paths:
|
|
|
1057
1084
|
|
|
1058
1085
|
```ts
|
|
1059
1086
|
export interface DomainVerifier {
|
|
1060
|
-
requestVerification(domain: string): Observable<unknown>;
|
|
1061
|
-
|
|
1087
|
+
requestVerification(domain: string): Observable<unknown>; // EC: send the code
|
|
1088
|
+
challenge?(domain: string, method: 'DT' | 'HF'): Observable<DomainChallenge>;
|
|
1089
|
+
confirm(domain: string, code: string, method?: DomainVerificationMethod): Observable<unknown>;
|
|
1062
1090
|
}
|
|
1063
1091
|
```
|
|
1064
1092
|
|
|
1093
|
+
`challenge` posts keel's `partner_domain` `challenge` action (`{domainUrl, method}`) and
|
|
1094
|
+
emits its `challenge`; `confirm` then posts `{domainUrl, method}` with an empty code.
|
|
1095
|
+
|
|
1065
1096
|
```ts
|
|
1066
1097
|
// app config — any service with those two methods satisfies the port
|
|
1067
1098
|
providers: [{ provide: DOMAIN_VERIFIER, useExisting: RegistrationService }]
|
|
@@ -1082,12 +1113,14 @@ providers: [{ provide: DOMAIN_VERIFIER, useExisting: RegistrationService }]
|
|
|
1082
1113
|
| `[label]` | Field label, default `Domain`. |
|
|
1083
1114
|
| `[resendAfterSeconds]` | Resend cooldown, default `60`. |
|
|
1084
1115
|
| `[codeExpiresInSeconds]` | Code lifetime; `0` (default) shows no expiry. Pass the backend's TTL when it publishes one. |
|
|
1116
|
+
| `[methods]` | Methods offered, default `['EC']`; the first is preselected. `DT` and `HF` appear only when the verifier implements `challenge`. Captions come from keel's `domain_verification_method` catalog. |
|
|
1085
1117
|
| `(domainChange)` | Every edit, so the host form stays in sync. |
|
|
1086
1118
|
| `(verified)` | Fires once with the proven domain. |
|
|
1087
1119
|
|
|
1088
1120
|
Editing the domain returns the flow to `idle`: a code is bound to the domain it was
|
|
1089
1121
|
issued for. The component is unstyled — style `.domain-verification`,
|
|
1090
|
-
`.domain-verification-field`, `.domain-verification-verified
|
|
1122
|
+
`.domain-verification-field`, `.domain-verification-verified`,
|
|
1123
|
+
`.domain-verification-method`, `.domain-verification-value` and the
|
|
1091
1124
|
`.verification-*` hooks of the step UI it composes.
|
|
1092
1125
|
|
|
1093
1126
|
## Notification inbox (v1.1.23)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nauticana/sail",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.31",
|
|
4
4
|
"description": "Metadata-driven Angular component library for CRUD admin frontends backed by the keel Go backend. Tables, forms, navigation, OTP/2FA/social auth, billing, payout onboarding.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"exports": {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { computed, inject, Injector, WritableSignal } from "@angular/core";
|
|
1
|
+
import { computed, inject, Injector, Type, WritableSignal } from "@angular/core";
|
|
2
2
|
import { ForeignKey, TableAction, TableColumn, TableDefinition } from "../../model/appdata";
|
|
3
3
|
import { ConstantValue, LookupStyle, OpCode } from "../../model/common";
|
|
4
4
|
import { BaseAuthService } from "../../service/auth.service";
|
|
@@ -9,6 +9,7 @@ import { errorDetail } from "../../util/errors";
|
|
|
9
9
|
import { MatDialog } from "@angular/material/dialog";
|
|
10
10
|
import { RevealDialog } from "../table/reveal_dialog";
|
|
11
11
|
import { ActionParamsDialog } from "../table/action_params_dialog";
|
|
12
|
+
import { IdentityProviderConfigureDialog } from "../sso/identity_provider_configure_dialog";
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
/** CRUD verb used by `requireAuth()` to compose the "Missing authorization" alert. */
|
|
@@ -17,6 +18,11 @@ export type AuthVerb = 'create' | 'read' | 'update' | 'delete' | 'view';
|
|
|
17
18
|
/** ISO-like timestamp prefix — gate for comparing two strings as instants. */
|
|
18
19
|
const ISO_PREFIX = /^\d{4}-\d{2}-\d{2}T/;
|
|
19
20
|
|
|
21
|
+
/** Parameter dialogs for keel actions the generic dialog cannot lay out, keyed `table/action`. */
|
|
22
|
+
const ACTION_DIALOGS: Record<string, Type<ActionParamsDialog>> = {
|
|
23
|
+
'partner_identity_provider/configure': IdentityProviderConfigureDialog,
|
|
24
|
+
};
|
|
25
|
+
|
|
20
26
|
|
|
21
27
|
export abstract class BaseTable {
|
|
22
28
|
protected readonly cacheService = inject(BaseAuthService);
|
|
@@ -146,7 +152,9 @@ export abstract class BaseTable {
|
|
|
146
152
|
const key = action.recordSpecific && record ? this.primaryKeyValues(record) : {};
|
|
147
153
|
if (action.parameters?.length) {
|
|
148
154
|
this.#injector.get(MatDialog)
|
|
149
|
-
.open<ActionParamsDialog, TableAction, Record<string, unknown>>(
|
|
155
|
+
.open<ActionParamsDialog, TableAction, Record<string, unknown>>(
|
|
156
|
+
ACTION_DIALOGS[this.tableName() + '/' + action.action] ?? ActionParamsDialog,
|
|
157
|
+
{ data: action, width: this.dialogWidth })
|
|
150
158
|
.afterClosed()
|
|
151
159
|
.subscribe((values) => {
|
|
152
160
|
if (values) this.postAction(action, {...key, ...values}, record);
|
|
@@ -38,6 +38,10 @@
|
|
|
38
38
|
(loginSuccess)="onSocialSuccess($event)"
|
|
39
39
|
(loginError)="onSocialError($event)" />
|
|
40
40
|
}
|
|
41
|
+
@if (guiConfig.ssoLogin) {
|
|
42
|
+
<hr class="auth-divider">
|
|
43
|
+
<sail-sso-login />
|
|
44
|
+
}
|
|
41
45
|
<div class="auth-actions">
|
|
42
46
|
<a routerLink="/login/chpass">I forgot my password</a>
|
|
43
47
|
</div>
|
|
@@ -9,6 +9,7 @@ import { MatInputModule } from '@angular/material/input';
|
|
|
9
9
|
import { SAIL_GUI_CONFIG, SailGuiConfig, DEFAULT_CONFIG } from '../../config';
|
|
10
10
|
import { LoginResponseSocial, SocialProvider } from '../../model/auth';
|
|
11
11
|
import { SocialLoginComponent } from './social_login';
|
|
12
|
+
import { SsoLoginComponent } from '../sso/sso_login';
|
|
12
13
|
|
|
13
14
|
@Component({
|
|
14
15
|
selector: 'sail-login',
|
|
@@ -22,6 +23,7 @@ import { SocialLoginComponent } from './social_login';
|
|
|
22
23
|
MatInputModule,
|
|
23
24
|
RouterLink,
|
|
24
25
|
SocialLoginComponent,
|
|
26
|
+
SsoLoginComponent,
|
|
25
27
|
],
|
|
26
28
|
})
|
|
27
29
|
export class LoginComponent extends BaseAsync {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
|
2
|
+
import { ReactiveFormsModule } from '@angular/forms';
|
|
3
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
4
|
+
import { MatCheckboxModule } from '@angular/material/checkbox';
|
|
5
|
+
import { MatDialogModule } from '@angular/material/dialog';
|
|
6
|
+
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
7
|
+
import { MatInputModule } from '@angular/material/input';
|
|
8
|
+
import { MatSelectModule } from '@angular/material/select';
|
|
9
|
+
import { TableActionParameter } from '../../model/appdata';
|
|
10
|
+
import { ConstantValue } from '../../model/common';
|
|
11
|
+
import { RestURL } from '../../service/rest_url';
|
|
12
|
+
import { ActionParamsDialog } from '../table/action_params_dialog';
|
|
13
|
+
|
|
14
|
+
/** identity_protocol code for SAML 2.0; anything else configures OpenID Connect. */
|
|
15
|
+
const SAML = 'S';
|
|
16
|
+
/** oidc_client_auth code for private key JWT; the other codes take a client secret. */
|
|
17
|
+
const PRIVATE_KEY_JWT = 'J';
|
|
18
|
+
const SAML_ONLY = new Set(['idp_metadata']);
|
|
19
|
+
const OIDC_ONLY = new Set(['issuer', 'client_id', 'client_auth', 'client_secret', 'private_key', 'operator_client', 'scopes']);
|
|
20
|
+
const DOMAINS: Record<string, string> = { protocol: 'identity_protocol', client_auth: 'oidc_client_auth' };
|
|
21
|
+
const CLIENT_FIELDS = new Set(['client_id', 'client_auth', 'client_secret', 'private_key']);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* keel's partner_identity_provider `configure` action: shows only the fields of the
|
|
25
|
+
* chosen protocol, and links keel's SAML service provider metadata.
|
|
26
|
+
*/
|
|
27
|
+
@Component({
|
|
28
|
+
selector: 'sail-identity-provider-configure-dialog',
|
|
29
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
30
|
+
imports: [
|
|
31
|
+
ReactiveFormsModule,
|
|
32
|
+
MatDialogModule,
|
|
33
|
+
MatButtonModule,
|
|
34
|
+
MatCheckboxModule,
|
|
35
|
+
MatFormFieldModule,
|
|
36
|
+
MatInputModule,
|
|
37
|
+
MatSelectModule,
|
|
38
|
+
],
|
|
39
|
+
templateUrl: '../table/action_params_dialog.html',
|
|
40
|
+
})
|
|
41
|
+
export class IdentityProviderConfigureDialog extends ActionParamsDialog {
|
|
42
|
+
protected override arrange(params: TableActionParameter[]): TableActionParameter[] {
|
|
43
|
+
return params
|
|
44
|
+
.map((param) => param.name === 'client_secret' ? { ...param, dataType: 'secret' } : param)
|
|
45
|
+
.sort((a, b) => Number(b.name === 'protocol') - Number(a.name === 'protocol'));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
protected override optionsFor(param: TableActionParameter): ConstantValue[] | undefined {
|
|
49
|
+
const domain = DOMAINS[param.name];
|
|
50
|
+
return domain ? this.auth.getDomainValues(domain) : super.optionsFor(param);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
protected override shown(name: string): boolean {
|
|
54
|
+
if (this.form.controls['protocol']?.value === SAML) return !OIDC_ONLY.has(name);
|
|
55
|
+
if (SAML_ONLY.has(name)) return false;
|
|
56
|
+
if (CLIENT_FIELDS.has(name) && String(this.form.controls['operator_client']?.value ?? '').trim()) return false;
|
|
57
|
+
const clientAuth = this.form.controls['client_auth']?.value;
|
|
58
|
+
if (name === 'client_secret') return clientAuth !== PRIVATE_KEY_JWT;
|
|
59
|
+
if (name === 'private_key') return clientAuth === PRIVATE_KEY_JWT;
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
protected override hint(name: string): { href: string; label: string } | null {
|
|
64
|
+
return name === 'idp_metadata'
|
|
65
|
+
? { href: RestURL.httpHost + RestURL.ssoSamlMetadataURL, label: 'Service provider metadata for your identity provider' }
|
|
66
|
+
: null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<div class="sso-login">
|
|
2
|
+
<mat-form-field appearance="outline">
|
|
3
|
+
<mat-label>Work email</mat-label>
|
|
4
|
+
<input matInput type="email" [formControl]="email" autocomplete="email" (keydown.enter)="continue()">
|
|
5
|
+
<mat-error>Enter your work email address</mat-error>
|
|
6
|
+
</mat-form-field>
|
|
7
|
+
<button matButton="elevated" type="button" (click)="continue()">Continue with your organization</button>
|
|
8
|
+
</div>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
|
|
2
|
+
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
3
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
4
|
+
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
5
|
+
import { MatInputModule } from '@angular/material/input';
|
|
6
|
+
import { ssoStartUrl } from '../../util/sso';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Organization sign-in: sends the browser to keel's `/public/sso/start` for the
|
|
10
|
+
* entered address. A top-level navigation, because keel binds the sign-in to an
|
|
11
|
+
* HttpOnly cookie on the API host.
|
|
12
|
+
*
|
|
13
|
+
* Selector: <sail-sso-login>
|
|
14
|
+
*/
|
|
15
|
+
@Component({
|
|
16
|
+
selector: 'sail-sso-login',
|
|
17
|
+
templateUrl: './sso_login.html',
|
|
18
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
19
|
+
encapsulation: ViewEncapsulation.None,
|
|
20
|
+
imports: [ReactiveFormsModule, MatButtonModule, MatFormFieldModule, MatInputModule],
|
|
21
|
+
})
|
|
22
|
+
export class SsoLoginComponent {
|
|
23
|
+
readonly email = new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email] });
|
|
24
|
+
|
|
25
|
+
continue(): void {
|
|
26
|
+
if (this.email.invalid) {
|
|
27
|
+
this.email.markAsTouched();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
window.location.assign(ssoStartUrl(this.email.value));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<div class="auth-container">
|
|
2
|
+
<div class="auth-card sso-return" [attr.data-state]="state()">
|
|
3
|
+
@switch (state()) {
|
|
4
|
+
@case ('signing-in') {
|
|
5
|
+
<p class="auth-message" role="status" aria-live="polite">Signing you in…</p>
|
|
6
|
+
}
|
|
7
|
+
@case ('test-passed') {
|
|
8
|
+
<p class="auth-message" role="status">Test sign-in succeeded. The connection can now be activated.</p>
|
|
9
|
+
}
|
|
10
|
+
@default {
|
|
11
|
+
<p class="auth-error" role="alert">{{ message() }}</p>
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
<div class="auth-actions">
|
|
15
|
+
@if (state() === 'error') {
|
|
16
|
+
<a routerLink="/login/local">Use another sign-in method</a>
|
|
17
|
+
}
|
|
18
|
+
@if (state() === 'test-passed' || state() === 'test-failed') {
|
|
19
|
+
@if (adminRoute(); as route) {
|
|
20
|
+
<a [routerLink]="route">Back to identity providers</a>
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, DestroyRef, ViewEncapsulation, inject, signal } from '@angular/core';
|
|
2
|
+
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
|
3
|
+
import { Location } from '@angular/common';
|
|
4
|
+
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
|
5
|
+
import { catchError, map, of } from 'rxjs';
|
|
6
|
+
import { DEFAULT_CONFIG, SAIL_GUI_CONFIG, SailGuiConfig } from '../../config';
|
|
7
|
+
import { ApplicationData } from '../../model/appdata';
|
|
8
|
+
import { BaseAuthService } from '../../service/auth.service';
|
|
9
|
+
import { ssoErrorMessage } from '../../util/sso';
|
|
10
|
+
|
|
11
|
+
type SsoReturnState = 'signing-in' | 'error' | 'test-passed' | 'test-failed';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* keel's single sign-on return URL (`SSOHandler.FrontendReturnURL`), routed by the
|
|
15
|
+
* app at `login/sso`. A `code` is exchanged once and removed from the address bar;
|
|
16
|
+
* `error` and `test` stay so the outcome survives the post-login route rebuild.
|
|
17
|
+
*
|
|
18
|
+
* Selector: <sail-sso-return>
|
|
19
|
+
*/
|
|
20
|
+
@Component({
|
|
21
|
+
selector: 'sail-sso-return',
|
|
22
|
+
templateUrl: './sso_return.html',
|
|
23
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
24
|
+
encapsulation: ViewEncapsulation.None,
|
|
25
|
+
imports: [RouterLink],
|
|
26
|
+
})
|
|
27
|
+
export class SsoReturnComponent {
|
|
28
|
+
private readonly auth = inject(BaseAuthService);
|
|
29
|
+
private readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, { optional: true }) ?? DEFAULT_CONFIG;
|
|
30
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
31
|
+
|
|
32
|
+
protected readonly state = signal<SsoReturnState>('signing-in');
|
|
33
|
+
protected readonly message = signal('');
|
|
34
|
+
/** The identity provider screen from the menu metadata, once the app data is loaded. */
|
|
35
|
+
protected readonly adminRoute = toSignal(
|
|
36
|
+
this.auth.getAppData().pipe(map(identityProviderRoute), catchError(() => of(null))),
|
|
37
|
+
{ initialValue: null },
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
constructor() {
|
|
41
|
+
const params = inject(ActivatedRoute).snapshot.queryParamMap;
|
|
42
|
+
const location = inject(Location);
|
|
43
|
+
const code = params.get('code');
|
|
44
|
+
const test = params.get('test');
|
|
45
|
+
if (code) {
|
|
46
|
+
location.replaceState(location.path().split('?')[0]);
|
|
47
|
+
this.exchange(code);
|
|
48
|
+
} else if (test === 'passed' || test === 'failed') {
|
|
49
|
+
this.state.set(test === 'passed' ? 'test-passed' : 'test-failed');
|
|
50
|
+
if (test === 'failed') this.message.set(this.messageFor(params.get('error')));
|
|
51
|
+
this.auth.setLanding(location.path());
|
|
52
|
+
this.destroyRef.onDestroy(() => this.auth.setLanding(null));
|
|
53
|
+
} else if (params.has('error')) {
|
|
54
|
+
this.fail(params.get('error'));
|
|
55
|
+
} else {
|
|
56
|
+
inject(Router).navigate(['/login/local'], { replaceUrl: true });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private exchange(code: string): void {
|
|
61
|
+
this.auth.exchangeHandoff(code).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
|
62
|
+
error: () => this.fail('sso_failed'),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private fail(code: string | null): void {
|
|
67
|
+
this.state.set('error');
|
|
68
|
+
this.message.set(this.messageFor(code));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private messageFor(code: string | null): string {
|
|
72
|
+
return ssoErrorMessage(code, this.guiConfig.ssoErrorMessages);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function identityProviderRoute(data: ApplicationData): string | null {
|
|
77
|
+
for (const menu of data.MainMenu ?? []) {
|
|
78
|
+
const item = menu.ApplicationMenuItems?.find((i) => i.RestUri === 'partner_identity_provider');
|
|
79
|
+
if (menu.Id && item?.ItemId) return `/${menu.Id.toLowerCase()}/${item.ItemId}`;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
@@ -5,40 +5,46 @@
|
|
|
5
5
|
<p>{{ action.confirmMessage }}</p>
|
|
6
6
|
}
|
|
7
7
|
@for (f of fields; track f.param.name) {
|
|
8
|
-
@
|
|
9
|
-
@
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
<
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
</div>
|
|
21
|
-
}
|
|
22
|
-
@case ('select') {
|
|
23
|
-
<mat-form-field appearance="outline">
|
|
24
|
-
<mat-label>{{ f.param.caption }}</mat-label>
|
|
25
|
-
<mat-select [formControlName]="f.param.name" [required]="f.param.required">
|
|
26
|
-
@if (!f.param.required) {
|
|
27
|
-
<mat-option [value]="''">None</mat-option>
|
|
28
|
-
}
|
|
29
|
-
@for (opt of f.options; track opt.Value) {
|
|
30
|
-
<mat-option [value]="opt.Value">{{ opt.Caption }}</mat-option>
|
|
8
|
+
@if (shown(f.param.name)) {
|
|
9
|
+
@switch (f.kind) {
|
|
10
|
+
@case ('checkbox') {
|
|
11
|
+
<mat-checkbox [formControlName]="f.param.name">{{ f.param.caption }}</mat-checkbox>
|
|
12
|
+
}
|
|
13
|
+
@case ('file') {
|
|
14
|
+
<div class="action-param-file">
|
|
15
|
+
<label [for]="'action-param-' + f.param.name">{{ f.param.caption }}@if (f.param.required) { * }</label>
|
|
16
|
+
<input type="file" [id]="'action-param-' + f.param.name" [required]="f.param.required"
|
|
17
|
+
(change)="pickFile(f.param.name, $event)">
|
|
18
|
+
@if (form.controls[f.param.name].touched && form.controls[f.param.name].invalid) {
|
|
19
|
+
<p class="action-param-error" role="alert">{{ f.param.caption }} is required</p>
|
|
31
20
|
}
|
|
32
|
-
</
|
|
33
|
-
|
|
34
|
-
|
|
21
|
+
</div>
|
|
22
|
+
}
|
|
23
|
+
@case ('select') {
|
|
24
|
+
<mat-form-field appearance="outline">
|
|
25
|
+
<mat-label>{{ f.param.caption }}</mat-label>
|
|
26
|
+
<mat-select [formControlName]="f.param.name" [required]="f.param.required">
|
|
27
|
+
@if (!f.param.required) {
|
|
28
|
+
<mat-option [value]="''">None</mat-option>
|
|
29
|
+
}
|
|
30
|
+
@for (opt of f.options; track opt.Value) {
|
|
31
|
+
<mat-option [value]="opt.Value">{{ opt.Caption }}</mat-option>
|
|
32
|
+
}
|
|
33
|
+
</mat-select>
|
|
34
|
+
<mat-error>{{ f.param.caption }} is required</mat-error>
|
|
35
|
+
</mat-form-field>
|
|
36
|
+
}
|
|
37
|
+
@default {
|
|
38
|
+
<mat-form-field appearance="outline">
|
|
39
|
+
<mat-label>{{ f.param.caption }}</mat-label>
|
|
40
|
+
<input matInput [type]="f.kind" [formControlName]="f.param.name" [required]="f.param.required"
|
|
41
|
+
[attr.autocomplete]="f.kind === 'password' ? 'new-password' : null">
|
|
42
|
+
<mat-error>{{ f.param.caption }} is required</mat-error>
|
|
43
|
+
</mat-form-field>
|
|
44
|
+
}
|
|
35
45
|
}
|
|
36
|
-
@
|
|
37
|
-
<
|
|
38
|
-
<mat-label>{{ f.param.caption }}</mat-label>
|
|
39
|
-
<input matInput [type]="f.kind" [formControlName]="f.param.name" [required]="f.param.required">
|
|
40
|
-
<mat-error>{{ f.param.caption }} is required</mat-error>
|
|
41
|
-
</mat-form-field>
|
|
46
|
+
@if (hint(f.param.name); as link) {
|
|
47
|
+
<p class="action-param-hint"><a [href]="link.href" target="_blank" rel="noopener">{{ link.label }}</a></p>
|
|
42
48
|
}
|
|
43
49
|
}
|
|
44
50
|
}
|
|
@@ -10,7 +10,7 @@ import { TableAction, TableActionParameter } from "../../model/appdata";
|
|
|
10
10
|
import { ConstantValue } from "../../model/common";
|
|
11
11
|
import { BaseAuthService } from "../../service/auth.service";
|
|
12
12
|
|
|
13
|
-
type ParamKind = 'checkbox' | 'number' | 'date' | 'datetime-local' | 'file' | 'text';
|
|
13
|
+
type ParamKind = 'checkbox' | 'number' | 'date' | 'datetime-local' | 'file' | 'password' | 'text';
|
|
14
14
|
|
|
15
15
|
interface ParamField {
|
|
16
16
|
param: TableActionParameter;
|
|
@@ -20,7 +20,11 @@ interface ParamField {
|
|
|
20
20
|
options?: ConstantValue[];
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Collects a table action's declared parameters; closes with the typed values, or
|
|
25
|
+
* undefined on cancel. A subclass for one action overrides arrange, optionsFor,
|
|
26
|
+
* shown and hint.
|
|
27
|
+
*/
|
|
24
28
|
@Component({
|
|
25
29
|
selector: "sail-action-params-dialog",
|
|
26
30
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
@@ -36,12 +40,12 @@ interface ParamField {
|
|
|
36
40
|
templateUrl: "./action_params_dialog.html",
|
|
37
41
|
})
|
|
38
42
|
export class ActionParamsDialog {
|
|
39
|
-
|
|
43
|
+
protected readonly auth = inject(BaseAuthService);
|
|
40
44
|
private readonly dialogRef = inject(MatDialogRef<ActionParamsDialog, Record<string, unknown>>);
|
|
41
45
|
readonly action = inject<TableAction>(MAT_DIALOG_DATA);
|
|
42
46
|
|
|
43
|
-
readonly fields: ParamField[] = (this.action.parameters ?? []).map((param) => {
|
|
44
|
-
const options =
|
|
47
|
+
readonly fields: ParamField[] = this.arrange(this.action.parameters ?? []).map((param) => {
|
|
48
|
+
const options = this.optionsFor(param);
|
|
45
49
|
const wireKind = kindOf(param.dataType);
|
|
46
50
|
return { param, kind: options ? 'select' : wireKind, wireKind, options };
|
|
47
51
|
});
|
|
@@ -53,12 +57,13 @@ export class ActionParamsDialog {
|
|
|
53
57
|
})));
|
|
54
58
|
|
|
55
59
|
submit(): void {
|
|
56
|
-
|
|
60
|
+
const shown = this.fields.filter((f) => this.shown(f.param.name));
|
|
61
|
+
if (shown.some((f) => this.form.controls[f.param.name].invalid)) {
|
|
57
62
|
this.form.markAllAsTouched();
|
|
58
63
|
return;
|
|
59
64
|
}
|
|
60
65
|
const values: Record<string, unknown> = {};
|
|
61
|
-
for (const f of
|
|
66
|
+
for (const f of shown) {
|
|
62
67
|
const raw = this.form.controls[f.param.name].value;
|
|
63
68
|
if (raw === '' || raw == null) continue;
|
|
64
69
|
values[f.param.name] = toWire(f.wireKind, raw);
|
|
@@ -66,6 +71,26 @@ export class ActionParamsDialog {
|
|
|
66
71
|
this.dialogRef.close(values);
|
|
67
72
|
}
|
|
68
73
|
|
|
74
|
+
/** The parameters in display order. */
|
|
75
|
+
protected arrange(params: TableActionParameter[]): TableActionParameter[] {
|
|
76
|
+
return params;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Choices for a parameter; undefined renders a typed input. */
|
|
80
|
+
protected optionsFor(param: TableActionParameter): ConstantValue[] | undefined {
|
|
81
|
+
return param.lookupTable ? this.auth.getTableValues(param.lookupTable) : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Whether a parameter applies to the values entered so far; a hidden one is not posted. */
|
|
85
|
+
protected shown(_name: string): boolean {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A link shown under a parameter. */
|
|
90
|
+
protected hint(_name: string): { href: string; label: string } | null {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
69
94
|
pickFile(name: string, event: Event): void {
|
|
70
95
|
const control = this.form.controls[name];
|
|
71
96
|
control.setValue((event.target as HTMLInputElement).files?.[0] ?? '');
|
|
@@ -91,6 +116,9 @@ function kindOf(dataType: string): ParamKind {
|
|
|
91
116
|
return 'datetime-local';
|
|
92
117
|
case 'file':
|
|
93
118
|
return 'file';
|
|
119
|
+
case 'secret':
|
|
120
|
+
case 'password':
|
|
121
|
+
return 'password';
|
|
94
122
|
default:
|
|
95
123
|
return 'text';
|
|
96
124
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<div class="domain-verification" [attr.data-step]="step()">
|
|
1
|
+
<div class="domain-verification" [attr.data-step]="step()" [attr.data-method]="method()">
|
|
2
2
|
<div class="domain-verification-field">
|
|
3
3
|
<mat-form-field appearance="outline">
|
|
4
4
|
<mat-label>{{ label() }}</mat-label>
|
|
@@ -11,13 +11,82 @@
|
|
|
11
11
|
}
|
|
12
12
|
</div>
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
14
|
+
@if (methods().length > 1 && !isVerified()) {
|
|
15
|
+
<mat-form-field appearance="outline" class="domain-verification-method">
|
|
16
|
+
<mat-label>Verification method</mat-label>
|
|
17
|
+
<mat-select [value]="method()" (selectionChange)="selectMethod($event.value)" [disabled]="loading()">
|
|
18
|
+
@for (opt of methodOptions(); track opt.value) {
|
|
19
|
+
<mat-option [value]="opt.value">{{ opt.caption }}</mat-option>
|
|
20
|
+
}
|
|
21
|
+
</mat-select>
|
|
22
|
+
</mat-form-field>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@if (method() === 'EC') {
|
|
26
|
+
<sail-verification-flow
|
|
27
|
+
[step]="step()"
|
|
28
|
+
[target]="domain()"
|
|
29
|
+
[errorMessage]="errorMessage()"
|
|
30
|
+
[disabled]="loading() || !domain().trim()"
|
|
31
|
+
[resendAfterSeconds]="resendAfterSeconds()"
|
|
32
|
+
[codeExpiresInSeconds]="codeExpiresInSeconds()"
|
|
33
|
+
(requestVerification)="request()"
|
|
34
|
+
(confirmCode)="confirm($event)" />
|
|
35
|
+
} @else {
|
|
36
|
+
<div class="verification-flow domain-verification-challenge" [attr.data-step]="step()">
|
|
37
|
+
@switch (step()) {
|
|
38
|
+
@case ('verified') {
|
|
39
|
+
<p class="verification-success" role="status">Verified.</p>
|
|
40
|
+
}
|
|
41
|
+
@case ('idle') {
|
|
42
|
+
@if (errorMessage()) { <p class="verification-error" role="alert">{{ errorMessage() }}</p> }
|
|
43
|
+
<button matButton="elevated" type="button" class="primary"
|
|
44
|
+
[disabled]="loading() || !domain().trim()" (click)="issueChallenge()">
|
|
45
|
+
Start verification
|
|
46
|
+
</button>
|
|
47
|
+
}
|
|
48
|
+
@default {
|
|
49
|
+
@if (challenge(); as ch) {
|
|
50
|
+
@if (ch.recordName) {
|
|
51
|
+
<p class="verification-intro">Add this TXT record to the DNS of {{ ch.domainName }}, then confirm.</p>
|
|
52
|
+
<div class="domain-verification-value">
|
|
53
|
+
<span>Name</span><code>{{ ch.recordName }}</code>
|
|
54
|
+
<button matIconButton type="button" (click)="copy(ch.recordName)" aria-label="Copy record name">
|
|
55
|
+
<mat-icon>content_copy</mat-icon>
|
|
56
|
+
</button>
|
|
57
|
+
</div>
|
|
58
|
+
<div class="domain-verification-value">
|
|
59
|
+
<span>Value</span><code>{{ ch.recordValue }}</code>
|
|
60
|
+
<button matIconButton type="button" (click)="copy(ch.recordValue ?? '')" aria-label="Copy record value">
|
|
61
|
+
<mat-icon>content_copy</mat-icon>
|
|
62
|
+
</button>
|
|
63
|
+
</div>
|
|
64
|
+
}
|
|
65
|
+
@if (ch.fileUrl) {
|
|
66
|
+
<p class="verification-intro">Serve a file with exactly this content at this address, then confirm.</p>
|
|
67
|
+
<div class="domain-verification-value">
|
|
68
|
+
<span>Address</span><code>{{ ch.fileUrl }}</code>
|
|
69
|
+
<button matIconButton type="button" (click)="copy(ch.fileUrl)" aria-label="Copy file address">
|
|
70
|
+
<mat-icon>content_copy</mat-icon>
|
|
71
|
+
</button>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="domain-verification-value">
|
|
74
|
+
<span>Content</span><code>{{ ch.fileBody }}</code>
|
|
75
|
+
<button matIconButton type="button" (click)="copy(ch.fileBody ?? '')" aria-label="Copy file content">
|
|
76
|
+
<mat-icon>content_copy</mat-icon>
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
@if (errorMessage()) { <p class="verification-error" role="alert">{{ errorMessage() }}</p> }
|
|
82
|
+
<div class="verification-actions">
|
|
83
|
+
<button matButton="elevated" type="button" class="primary"
|
|
84
|
+
[disabled]="loading()" (click)="confirm('')">
|
|
85
|
+
@if (step() === 'verifying') { Verifying… } @else { Confirm }
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
</div>
|
|
91
|
+
}
|
|
23
92
|
</div>
|
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ChangeDetectionStrategy, Component, ViewEncapsulation,
|
|
3
|
-
computed, effect, inject, input, output, signal, untracked,
|
|
3
|
+
computed, effect, inject, input, linkedSignal, output, signal, untracked,
|
|
4
4
|
} from '@angular/core';
|
|
5
|
+
import { Clipboard } from '@angular/cdk/clipboard';
|
|
6
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
5
7
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
6
8
|
import { MatIconModule } from '@angular/material/icon';
|
|
7
9
|
import { MatInputModule } from '@angular/material/input';
|
|
10
|
+
import { MatSelectModule } from '@angular/material/select';
|
|
8
11
|
import { VerificationStep } from '../../model/dashboard';
|
|
9
|
-
import {
|
|
12
|
+
import { BaseAuthService } from '../../service/auth.service';
|
|
13
|
+
import { DOMAIN_VERIFIER, DomainChallenge, DomainVerificationMethod } from '../../service/domain_verifier';
|
|
10
14
|
import { BaseAsync } from '../abstract/base_async';
|
|
11
15
|
import { VerificationFlowComponent } from '../dashboard/verification_flow';
|
|
12
16
|
|
|
13
17
|
/**
|
|
14
|
-
* Domain-ownership verification: the domain field plus the
|
|
15
|
-
*
|
|
16
|
-
*
|
|
18
|
+
* Domain-ownership verification: the domain field plus the steps of the chosen method
|
|
19
|
+
* (email code, DNS TXT record or HTTP file), driven against the injected
|
|
20
|
+
* `DOMAIN_VERIFIER`. The app owns the endpoints, the page shell and the styling;
|
|
21
|
+
* `verified` fires once with the domain that was proven.
|
|
17
22
|
*
|
|
18
23
|
* Selector: <sail-domain-verification>
|
|
19
24
|
*/
|
|
@@ -22,15 +27,19 @@ import { VerificationFlowComponent } from '../dashboard/verification_flow';
|
|
|
22
27
|
templateUrl: './domain_verification.html',
|
|
23
28
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
24
29
|
encapsulation: ViewEncapsulation.None,
|
|
25
|
-
imports: [MatFormFieldModule, MatIconModule, MatInputModule, VerificationFlowComponent],
|
|
30
|
+
imports: [MatButtonModule, MatFormFieldModule, MatIconModule, MatInputModule, MatSelectModule, VerificationFlowComponent],
|
|
26
31
|
})
|
|
27
32
|
export class DomainVerificationComponent extends BaseAsync {
|
|
28
33
|
private readonly verifier = inject(DOMAIN_VERIFIER);
|
|
34
|
+
private readonly auth = inject(BaseAuthService, { optional: true });
|
|
35
|
+
private readonly clipboard = inject(Clipboard);
|
|
29
36
|
|
|
30
37
|
readonly domainInput = input('', { alias: 'domain' });
|
|
31
38
|
readonly label = input('Domain');
|
|
32
39
|
readonly resendAfterSeconds = input(60);
|
|
33
40
|
readonly codeExpiresInSeconds = input(0);
|
|
41
|
+
/** Methods offered, first is the default; DT and HF need `DomainVerifier.challenge`. */
|
|
42
|
+
readonly methodsInput = input<DomainVerificationMethod[]>(['EC'], { alias: 'methods' });
|
|
34
43
|
|
|
35
44
|
readonly domainChange = output<string>();
|
|
36
45
|
readonly verified = output<string>();
|
|
@@ -38,6 +47,14 @@ export class DomainVerificationComponent extends BaseAsync {
|
|
|
38
47
|
readonly domain = signal('');
|
|
39
48
|
readonly step = signal<VerificationStep>('idle');
|
|
40
49
|
readonly isVerified = computed(() => this.step() === 'verified');
|
|
50
|
+
readonly methods = computed(() => this.methodsInput().filter((m) => m === 'EC' || !!this.verifier.challenge));
|
|
51
|
+
readonly method = linkedSignal<DomainVerificationMethod>(() => this.methods()[0] ?? 'EC');
|
|
52
|
+
readonly challenge = signal<DomainChallenge | null>(null);
|
|
53
|
+
/** Captions from keel's domain_verification_method catalog; the code when it is not loaded. */
|
|
54
|
+
protected readonly methodOptions = computed(() => {
|
|
55
|
+
const captions = this.auth?.getDomainValues('domain_verification_method') ?? [];
|
|
56
|
+
return this.methods().map((value) => ({ value, caption: captions.find((c) => c.Value === value)?.Caption ?? value }));
|
|
57
|
+
});
|
|
41
58
|
|
|
42
59
|
constructor() {
|
|
43
60
|
super();
|
|
@@ -45,18 +62,45 @@ export class DomainVerificationComponent extends BaseAsync {
|
|
|
45
62
|
const value = this.domainInput();
|
|
46
63
|
if (value === untracked(this.domain)) return;
|
|
47
64
|
this.domain.set(value);
|
|
48
|
-
this.
|
|
49
|
-
this.clearMessages();
|
|
65
|
+
this.reset();
|
|
50
66
|
});
|
|
51
67
|
}
|
|
52
68
|
|
|
53
69
|
protected onDomainInput(value: string): void {
|
|
54
70
|
this.domain.set(value);
|
|
55
|
-
this.
|
|
56
|
-
this.clearMessages();
|
|
71
|
+
this.reset(); // a code or challenge is bound to the domain it was issued for
|
|
57
72
|
this.domainChange.emit(value);
|
|
58
73
|
}
|
|
59
74
|
|
|
75
|
+
protected selectMethod(method: DomainVerificationMethod): void {
|
|
76
|
+
this.method.set(method);
|
|
77
|
+
this.reset();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
protected issueChallenge(): void {
|
|
81
|
+
const domain = this.domain().trim();
|
|
82
|
+
const method = this.method();
|
|
83
|
+
if (!domain || method === 'EC' || !this.verifier.challenge || this.isVerified() || this.loading()) return;
|
|
84
|
+
this.run(
|
|
85
|
+
this.verifier.challenge(domain, method),
|
|
86
|
+
(challenge) => {
|
|
87
|
+
this.challenge.set(challenge);
|
|
88
|
+
this.step.set('requested');
|
|
89
|
+
},
|
|
90
|
+
'Could not start the verification.',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
protected copy(value: string): void {
|
|
95
|
+
this.clipboard.copy(value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private reset(): void {
|
|
99
|
+
this.step.set('idle');
|
|
100
|
+
this.challenge.set(null);
|
|
101
|
+
this.clearMessages();
|
|
102
|
+
}
|
|
103
|
+
|
|
60
104
|
protected request(): void {
|
|
61
105
|
const domain = this.domain().trim();
|
|
62
106
|
if (!domain || this.isVerified() || this.loading()) return;
|
|
@@ -68,12 +112,13 @@ export class DomainVerificationComponent extends BaseAsync {
|
|
|
68
112
|
);
|
|
69
113
|
}
|
|
70
114
|
|
|
115
|
+
/** Confirms the email code, or the published challenge when `code` is empty. */
|
|
71
116
|
protected confirm(code: string): void {
|
|
72
117
|
const domain = this.domain().trim();
|
|
73
118
|
if (!domain || this.isVerified() || this.loading()) return;
|
|
74
119
|
this.step.set('verifying');
|
|
75
120
|
this.run(
|
|
76
|
-
this.verifier.confirm(domain, code),
|
|
121
|
+
this.verifier.confirm(domain, code, this.method()),
|
|
77
122
|
() => {
|
|
78
123
|
this.step.set('verified');
|
|
79
124
|
this.verified.emit(domain);
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { InjectionToken, Type } from '@angular/core';
|
|
2
2
|
import { Routes } from '@angular/router';
|
|
3
3
|
import { ApplicationData } from './model/appdata';
|
|
4
|
+
import { SsoErrorCode } from './util/sso';
|
|
4
5
|
|
|
5
6
|
export interface RouteLink {
|
|
6
7
|
label: string;
|
|
@@ -75,6 +76,11 @@ export interface SailGuiConfig {
|
|
|
75
76
|
* e.g. ['mcp.example.com']. Anything not listed is ignored. */
|
|
76
77
|
allowedReturnHosts?: string[];
|
|
77
78
|
|
|
79
|
+
/** Shows `<sail-sso-login>` (organization sign-in) on `<sail-login>`. Off by default. */
|
|
80
|
+
ssoLogin?: boolean;
|
|
81
|
+
/** Replaces the default wording of single sign-on error codes. */
|
|
82
|
+
ssoErrorMessages?: Partial<Record<SsoErrorCode, string>>;
|
|
83
|
+
|
|
78
84
|
/** Public route serving the password policy (keel PublicHandler.GetPasswordPolicy),
|
|
79
85
|
* e.g. '/public/v1/auth/password/policy'. When set, sail fetches it once and
|
|
80
86
|
* password inputs validate against it. Omitted → policy validation is skipped. */
|
package/src/index.ts
CHANGED
|
@@ -45,6 +45,8 @@ export * from './component/login/sms_consent';
|
|
|
45
45
|
export * from './component/login/otp_input';
|
|
46
46
|
export * from './component/login/social_login';
|
|
47
47
|
export * from './component/login/account_deletion';
|
|
48
|
+
export * from './component/sso/sso_login';
|
|
49
|
+
export * from './component/sso/sso_return';
|
|
48
50
|
export * from './component/account/profile_editor';
|
|
49
51
|
export * from './component/account/my_account';
|
|
50
52
|
export * from './component/security/twofactor_setup';
|
|
@@ -96,3 +98,4 @@ export * from './util/text';
|
|
|
96
98
|
export * from './util/money';
|
|
97
99
|
export * from './util/errors';
|
|
98
100
|
export * from './util/password_policy';
|
|
101
|
+
export * from './util/sso';
|
|
@@ -80,6 +80,7 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
80
80
|
/** The route config active before the first initRoutes() — restored on logout
|
|
81
81
|
* so the login screen doesn't keep the previous user's route tree. */
|
|
82
82
|
private preLoginRoutes: Routes | null = null;
|
|
83
|
+
private landing: string | null = null;
|
|
83
84
|
|
|
84
85
|
// Lazy getters, not field initializers: fields would snapshot during super(),
|
|
85
86
|
// before a subclass constructor can call configureRestUrls().
|
|
@@ -640,6 +641,7 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
640
641
|
this.appDataError.set(null);
|
|
641
642
|
this.sessionHandoffError.set(null);
|
|
642
643
|
this.refreshInFlight = null;
|
|
644
|
+
this.landing = null;
|
|
643
645
|
this.sessionGeneration++; // in-flight loads and refreshes from this session are ignored
|
|
644
646
|
this.appData$.next(null); // live subscribers see menus clear; identity stays stable
|
|
645
647
|
this.appDataFail$.complete();
|
|
@@ -770,14 +772,14 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
770
772
|
canCreate(tableName: string) { return this.checkPermission('TABLE', 'INSERT', tableName); }
|
|
771
773
|
canUpdate(tableName: string) { return this.checkPermission('TABLE', 'UPDATE', tableName); }
|
|
772
774
|
canDelete(tableName: string) { return this.checkPermission('TABLE', 'DELETE', tableName); }
|
|
773
|
-
/**
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
canExecute(authorityObject: string, activity: string,
|
|
780
|
-
return this.
|
|
775
|
+
/** Whether a grant's low_limit pattern matches value. */
|
|
776
|
+
hasPermission(authorityObject: string, activity: string, value: string): boolean {
|
|
777
|
+
return this.checkPermission(authorityObject, activity, value);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** Authorization check for a custom TableAction. */
|
|
781
|
+
canExecute(authorityObject: string, activity: string, value: string): boolean {
|
|
782
|
+
return this.hasPermission(authorityObject, activity, value);
|
|
781
783
|
}
|
|
782
784
|
canAccess(pageName: string) { return this.checkPermission('PAGE', 'ACCESS', pageName); }
|
|
783
785
|
canReport(reportName: string) { return this.checkPermission('REPORT', 'ACCESS', reportName); }
|
|
@@ -826,12 +828,23 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
826
828
|
return undefined;
|
|
827
829
|
}
|
|
828
830
|
|
|
831
|
+
/** Lands the next route build on `url` instead of the default target, so a
|
|
832
|
+
* return page under /login keeps its outcome; null withdraws it. */
|
|
833
|
+
setLanding(url: string | null): void {
|
|
834
|
+
this.landing = url;
|
|
835
|
+
}
|
|
836
|
+
|
|
829
837
|
/**
|
|
830
838
|
* Where to land after routes are (re)built. A fresh login starts from the
|
|
831
839
|
* root or a `/login` route → go to the dashboard. A refresh / bookmark of an
|
|
832
840
|
* authenticated route → preserve it so deep links survive route restoration.
|
|
833
841
|
*/
|
|
834
842
|
private postLoginTarget(): string {
|
|
843
|
+
if (this.landing) {
|
|
844
|
+
const url = this.landing;
|
|
845
|
+
this.landing = null;
|
|
846
|
+
return url;
|
|
847
|
+
}
|
|
835
848
|
const path = window.location.pathname;
|
|
836
849
|
const preserve = !!path && path !== '/' && !path.startsWith('/login');
|
|
837
850
|
return preserve ? path + window.location.search : '/dashboard';
|
|
@@ -1,20 +1,38 @@
|
|
|
1
1
|
import { InjectionToken } from '@angular/core';
|
|
2
2
|
import { Observable } from 'rxjs';
|
|
3
3
|
|
|
4
|
+
/** keel domain_verification_method codes the component can run: email code, DNS TXT record, HTTP file. */
|
|
5
|
+
export type DomainVerificationMethod = 'EC' | 'DT' | 'HF';
|
|
6
|
+
|
|
7
|
+
/** keel's issued challenge (the `challenge` of the `partner_domain` `challenge` action). */
|
|
8
|
+
export interface DomainChallenge {
|
|
9
|
+
method: string;
|
|
10
|
+
domainUrl: string;
|
|
11
|
+
domainName: string;
|
|
12
|
+
expiresAt: string;
|
|
13
|
+
recordName?: string;
|
|
14
|
+
recordValue?: string;
|
|
15
|
+
fileUrl?: string;
|
|
16
|
+
fileBody?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
4
19
|
/**
|
|
5
|
-
* Port for proving ownership of a domain
|
|
20
|
+
* Port for proving ownership of a domain.
|
|
6
21
|
*
|
|
7
22
|
* sail ships no implementation — the endpoints, their paths and their payloads are
|
|
8
23
|
* the app's. Register one at composition time:
|
|
9
24
|
*
|
|
10
25
|
* { provide: DOMAIN_VERIFIER, useExisting: RegistrationService }
|
|
11
26
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
27
|
+
* `requestVerification` sends an email code. `challenge` issues a DNS TXT or HTTP
|
|
28
|
+
* file challenge; implement it to offer those methods. `confirm` receives the
|
|
29
|
+
* method for a challenge and an empty code. `DomainVerificationComponent` reads only
|
|
30
|
+
* success or failure of the other calls, and reports a failure through `errorDetail()`.
|
|
14
31
|
*/
|
|
15
32
|
export interface DomainVerifier {
|
|
16
33
|
requestVerification(domain: string): Observable<unknown>;
|
|
17
|
-
|
|
34
|
+
challenge?(domain: string, method: Exclude<DomainVerificationMethod, 'EC'>): Observable<DomainChallenge>;
|
|
35
|
+
confirm(domain: string, code: string, method?: DomainVerificationMethod): Observable<unknown>;
|
|
18
36
|
}
|
|
19
37
|
|
|
20
38
|
export const DOMAIN_VERIFIER = new InjectionToken<DomainVerifier>('SailDomainVerifier');
|
package/src/service/rest_url.ts
CHANGED
|
@@ -38,6 +38,9 @@ export const RestURL = {
|
|
|
38
38
|
otpVerifyURL: '/public/otp/verify',
|
|
39
39
|
otpResendURL: '/public/otp/resend',
|
|
40
40
|
loginSocialURL: '/public/login/social',
|
|
41
|
+
// Tenant single sign-on (keel SSOHandler): top-level navigations, never XHR.
|
|
42
|
+
ssoStartURL: '/public/sso/start',
|
|
43
|
+
ssoSamlMetadataURL: '/public/sso/saml/metadata',
|
|
41
44
|
logoutEverywhereURL: '/api/user/logout-everywhere',
|
|
42
45
|
deleteAccountURL: '/api/user/account',
|
|
43
46
|
// Self-service profile (keel ProfileHandler). GET profileURL reads the
|
package/src/util/sso.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { RestURL } from '../service/rest_url';
|
|
2
|
+
|
|
3
|
+
/** Error codes keel's SSOHandler sends back to the return URL as `?error=`. */
|
|
4
|
+
export type SsoErrorCode =
|
|
5
|
+
| 'sso_unavailable'
|
|
6
|
+
| 'sso_failed'
|
|
7
|
+
| 'mfa_required'
|
|
8
|
+
| 'sso_email_not_allowed'
|
|
9
|
+
| 'sso_no_account'
|
|
10
|
+
| 'sso_other_partner'
|
|
11
|
+
| 'sso_required'
|
|
12
|
+
| 'account_unavailable'
|
|
13
|
+
| 'identity_linked'
|
|
14
|
+
| 'sso_test_mismatch'
|
|
15
|
+
| 'domain_not_proven'
|
|
16
|
+
| 'server_error';
|
|
17
|
+
|
|
18
|
+
/** Default wording; an app overrides entries through `SailGuiConfig.ssoErrorMessages`. */
|
|
19
|
+
export const SSO_ERROR_MESSAGES: Record<SsoErrorCode, string> = {
|
|
20
|
+
sso_unavailable: 'Single sign-on is not available for this email address. Use another sign-in method.',
|
|
21
|
+
sso_failed: 'The sign-in could not be completed. Please try again.',
|
|
22
|
+
mfa_required: 'Your organization requires multi-factor authentication. Complete it at your organization\'s sign-in page and try again.',
|
|
23
|
+
sso_email_not_allowed: 'This account is outside your organization\'s verified domains.',
|
|
24
|
+
sso_no_account: 'You do not have an account yet. Ask your organization\'s administrator for access.',
|
|
25
|
+
sso_other_partner: 'This account belongs to another organization.',
|
|
26
|
+
sso_required: 'Your organization requires you to sign in with single sign-on.',
|
|
27
|
+
account_unavailable: 'This account is locked or expired. Contact your administrator.',
|
|
28
|
+
identity_linked: 'This account is already linked to a different identity at your organization\'s identity provider.',
|
|
29
|
+
sso_test_mismatch: 'The identity provider signed in someone other than you. Sign in there as yourself and test again.',
|
|
30
|
+
domain_not_proven: 'The identity provider did not prove your organization\'s domain.',
|
|
31
|
+
server_error: 'Something went wrong. Please try again.',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** The message for a returned code; an unknown code reads as `server_error`. */
|
|
35
|
+
export function ssoErrorMessage(code: string | null | undefined, overrides?: Partial<Record<SsoErrorCode, string>>): string {
|
|
36
|
+
const key = (code && code in SSO_ERROR_MESSAGES ? code : 'server_error') as SsoErrorCode;
|
|
37
|
+
return overrides?.[key] ?? SSO_ERROR_MESSAGES[key];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** keel's `/public/sso/start` for an address, on the API host. */
|
|
41
|
+
export function ssoStartUrl(email: string): string {
|
|
42
|
+
return RestURL.httpHost + RestURL.ssoStartURL + '?' + new URLSearchParams({ email: email.trim() }).toString();
|
|
43
|
+
}
|