@nauticana/sail 1.1.30 → 1.1.32

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 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.32 adopts keel v1.2.101 (the OAuth connect callback ticket), 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`) is the send-code → confirm
1051
- flow for proving ownership of a domain: the domain field, the two steps, the resend
1052
- cooldown, the expiry state and the errors. Every app that verifies a domain renders
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
- confirm(domain: string, code: string): Observable<unknown>;
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` and the
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)
@@ -1209,8 +1242,19 @@ catalog and any routing via the outputs.
1209
1242
  - Each `OAuthProviderField` has a **`role`**: `credential` (sealed secret), `endpoint` (stored
1210
1243
  as `api_endpoint`), or `param` (OAuth authorize query param) — so field order is
1211
1244
  irrelevant. `icon` is a Material name or an image URL.
1245
+ - **Connect flow (keel v1.2.101).** `startOAuth` sends the signed-in user to provider consent.
1246
+ keel's callback validates the response, parks it under a single-use ticket and redirects the
1247
+ browser to its `FrontendReturnURL` with `?connect=<provider>&ticket=<ticket>`. The panel on
1248
+ that page strips both parameters from the URL (hash preserved, so a reload never re-posts a
1249
+ spent ticket), posts the ticket with the user's bearer token to `/api/oauth/<provider>/complete`,
1250
+ then reloads and emits `connected` once the row is visible. A `connect` value that is not a
1251
+ configured provider is ignored. keel binds the connection to the user who started the flow
1252
+ (RFC 6749 §10.12): a 403 ("started by another user") and a 400 (expired or already used
1253
+ ticket) show distinct messages asking the user to start again. **The page at
1254
+ `FrontendReturnURL` must require sign-in before the panel runs.**
1212
1255
  - **`OAuthConnectionService`** — `startOAuth(provider, extra)` → consent URL,
1213
- `testConnection`, `list(entityId)` / `disconnect` (generic `partner_credential` REST), and
1256
+ `completeOAuth(provider, ticket)` → `{status, provider}`, `testConnection`,
1257
+ `list(entityId)` / `disconnect` (generic `partner_credential` REST), and
1214
1258
  `saveApiKey` (POSTs the sealed `/api/oauth/apikey`).
1215
1259
 
1216
1260
  ```html
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nauticana/sail",
3
- "version": "1.1.30",
3
+ "version": "1.1.32",
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>>(ActionParamsDialog, { data: action, width: this.dialogWidth })
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 {
@@ -62,21 +62,38 @@ export class OAuthConnectionPanelComponent implements OnInit {
62
62
  });
63
63
  }
64
64
 
65
- // After an OAuth round-trip keel redirects back with ?<provider>=success. Record
66
- // the codes (configured providers only), strip them — preserving the hash — and
67
- // emit `connected` once the reload confirms the row exists (confirmSuccess).
65
+ // After the provider callback keel redirects back with ?connect=<provider>&ticket=…
66
+ // The ticket is single-use, so strip it (hash preserved) before redeeming it for
67
+ // the signed-in user; the reload then confirms the row exists (confirmSuccess).
68
68
  ngOnInit(): void {
69
69
  const q = new URLSearchParams(window.location.search);
70
- const known = new Set(this.providers().map((p) => p.code));
71
- const succeeded = new Set<string>();
72
- q.forEach((value, key) => {
73
- if (value === 'success' && known.has(key)) succeeded.add(key);
74
- });
75
- if (!succeeded.size) return;
76
- this.pendingSuccess.set(succeeded);
77
- for (const code of succeeded) q.delete(code);
70
+ const provider = q.get('connect') ?? '';
71
+ const ticket = q.get('ticket') ?? '';
72
+ if (!provider && !ticket) return;
73
+ q.delete('connect');
74
+ q.delete('ticket');
78
75
  const qs = q.toString();
79
76
  window.history.replaceState({}, '', window.location.pathname + (qs ? '?' + qs : '') + window.location.hash);
77
+ if (!ticket || !this.providers().some((p) => p.code === provider)) return;
78
+ this.busy.set(provider);
79
+ this.svc.completeOAuth(provider, ticket).subscribe({
80
+ next: () => {
81
+ this.busy.set('');
82
+ this.pendingSuccess.set(new Set([provider]));
83
+ this.reload(this.entityId());
84
+ },
85
+ error: (e) => {
86
+ this.busy.set('');
87
+ this.error.set(this.completeMsg(e));
88
+ },
89
+ });
90
+ }
91
+
92
+ private completeMsg(e: unknown): string {
93
+ const status = (e as { status?: number })?.status;
94
+ if (status === 403) return 'This connection was started by another user — start again from this account.';
95
+ if (status === 400) return 'The connection link expired — start again.';
96
+ return this.msg(e, 'Could not complete the connection.');
80
97
  }
81
98
 
82
99
  private reload(entityId: number): void {
@@ -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
- @switch (f.kind) {
9
- @case ('checkbox') {
10
- <mat-checkbox [formControlName]="f.param.name">{{ f.param.caption }}</mat-checkbox>
11
- }
12
- @case ('file') {
13
- <div class="action-param-file">
14
- <label [for]="'action-param-' + f.param.name">{{ f.param.caption }}@if (f.param.required) { * }</label>
15
- <input type="file" [id]="'action-param-' + f.param.name" [required]="f.param.required"
16
- (change)="pickFile(f.param.name, $event)">
17
- @if (form.controls[f.param.name].touched && form.controls[f.param.name].invalid) {
18
- <p class="action-param-error" role="alert">{{ f.param.caption }} is required</p>
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
- </mat-select>
33
- <mat-error>{{ f.param.caption }} is required</mat-error>
34
- </mat-form-field>
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
- @default {
37
- <mat-form-field appearance="outline">
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
- /** Collects a table action's declared parameters; closes with the typed values, or undefined on cancel. */
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
- private readonly auth = inject(BaseAuthService);
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 = param.lookupTable ? this.auth.getTableValues(param.lookupTable) : undefined;
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
- if (this.form.invalid) {
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 this.fields) {
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
- <sail-verification-flow
15
- [step]="step()"
16
- [target]="domain()"
17
- [errorMessage]="errorMessage()"
18
- [disabled]="loading() || !domain().trim()"
19
- [resendAfterSeconds]="resendAfterSeconds()"
20
- [codeExpiresInSeconds]="codeExpiresInSeconds()"
21
- (requestVerification)="request()"
22
- (confirmCode)="confirm($event)" />
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 { DOMAIN_VERIFIER } from '../../service/domain_verifier';
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 send-code → confirm steps,
15
- * driven against the injected `DOMAIN_VERIFIER`. The app owns the endpoints, the page
16
- * shell and the styling; `verified` fires once with the domain that was proven.
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.step.set('idle');
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.step.set('idle'); // a code is bound to the domain it was issued for
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
- * Authorization check for a custom TableAction. authorityObject is the
775
- * uppercased table_name; activity is the uppercased action_name. The
776
- * grant's low_limit is checked against the table_name (lowercase) —
777
- * matching the convention populated by keel's loadTableActions.
778
- */
779
- canExecute(authorityObject: string, activity: string, tableName: string): boolean {
780
- return this.checkPermission(authorityObject, activity, tableName);
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: issue a code, then confirm it.
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
- * Both methods may emit any body; `DomainVerificationComponent` reads only success
13
- * or failure, and reports the failure through `errorDetail()`.
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
- confirm(domain: string, code: string): Observable<unknown>;
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');
@@ -24,6 +24,16 @@ export class OAuthConnectionService extends BaseRestService {
24
24
  return this.http.get<{ url: string }>(this.url(path)).pipe(map((r) => r.url));
25
25
  }
26
26
 
27
+ /** Finish an OAuth connect flow: redeem the single-use callback ticket keel put
28
+ * in `?connect=<provider>&ticket=...` for the signed-in user who started it.
29
+ * 400 = expired or spent ticket; 403 = started by another user or partner. */
30
+ completeOAuth(provider: string, ticket: string): Observable<{ status: string; provider: string }> {
31
+ return this.http.post<{ status: string; provider: string }>(
32
+ this.url(`/api/oauth/${encodeURIComponent(provider)}/complete`),
33
+ { ticket },
34
+ );
35
+ }
36
+
27
37
  /** Re-validate a stored connection (POST — it makes an outbound call and updates
28
38
  * status). entityId selects which one (0 = tenant-wide). */
29
39
  testConnection(provider: string, entityId = 0): Observable<{ status: string; message?: string }> {
@@ -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
@@ -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
+ }