@nauticana/sail 1.1.29 → 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 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.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
 
@@ -350,6 +354,9 @@ Omit the URL to skip client-side policy checks. The server stays the authoritati
350
354
  | `POST /public/register/confirm` | Confirm `?email=&code=`; answers session tokens and the partner result (`confirmRegister`) |
351
355
  | `POST /public/register/exchange` | Trade a single-use hand-off `{code}` for a session (`exchangeHandoff`) |
352
356
  | `POST /api/register/partner` | Create the signed-in user's partner from a `PartnerSetup` (`createPartner`) |
357
+ | `POST /public/password/change` | Change `{username, old_password, new_password}` (`chpass`); keel revokes every refresh token |
358
+ | `POST /public/password/forgot` | Email a reset code for `{username}` (`forgotPassword`) |
359
+ | `POST /public/password/reset` | Set the new password from `{username, code, new_password}` (`confirmChpass`); revokes every refresh token |
353
360
  | `POST /public/token/refresh` | Rotate `{refreshToken}` → new `{token, refreshToken}`; 401 ends the session |
354
361
  | `POST /public/logout` | Revoke `{refreshToken}` (called by `BaseAuthService.logout()`) |
355
362
  | `GET /public/ws` | keel realtime hub handshake (`RealtimeService`; JWT via `?token=`) |
@@ -417,6 +424,8 @@ The session JWT, the refresh token, and the short-lived `loginToken` are kept in
417
424
 
418
425
  Every keel login response (keel v1.2.57+) carries `refreshToken` beside `token`; `completeLogin` stores both. When an authenticated call answers 401, `authInterceptor` calls `BaseAuthService.refreshSession()` — one shared `POST /public/token/refresh` per burst — stores the rotated pair and replays the request with the new JWT. A 401 from the refresh itself (revoked, expired, already rotated) clears the session and lands on `login?sessionExpired=true`; any other refresh failure keeps the session and lets the 401 count toward the circuit breaker. `logout()` posts the refresh token to `/public/logout` before clearing local state. `acceptToken(token, refreshToken?)` accepts the pair for handoff flows. No wiring beyond registering `authInterceptor`.
419
426
 
427
+ A successful `chpass()` or `confirmChpass()` clears any current session and lands on `login?passwordChanged=true`, where `<sail-login>` asks the user to sign in with the new password.
428
+
420
429
  ### Realtime
421
430
 
422
431
  `RealtimeService` (root-provided) is the client for keel's `realtime.Hub`. `connect()` opens `RestURL.realtimeURL` (`/public/ws`) on the configured backend host with the stored JWT, reconnects with backoff, and re-subscribes every channel after a reconnect. `channel<T>(name)` returns the payloads keel broadcasts on that channel; `userFrames<T>()` returns payloads addressed to the user, including keel's `{op: "notification"}` frames; `send(frame)` forwards an application frame to the hub's `OnMessage`. A subscription keel's `CanSubscribe` rejects is logged and exposed on `lastError`. Channel names and payload shapes are app-owned.
@@ -596,7 +605,7 @@ A `TableAction` with `kind: 'R'` (redirect — the `constant_value` code) makes
596
605
 
597
606
  ### Action parameters (v1.1.25)
598
607
 
599
- 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.
600
609
 
601
610
  ### Status chip (v1.0.1)
602
611
 
@@ -1001,6 +1010,29 @@ Under the hood, the component calls `BaseAuthService.loginSocial(provider, idTok
1001
1010
 
1002
1011
  For backward compatibility, the older OAuth-code flow `BaseAuthService.loginWithGoogle(code)` (hits `/public/login/google`) is still supported; prefer `loginSocial` for new code.
1003
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
+
1004
1036
  ## Dashboard components
1005
1037
 
1006
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.
@@ -1040,11 +1072,11 @@ unless the backend sends
1040
1072
  `source.status` is `null` and a missing source is indistinguishable from an
1041
1073
  empty result.
1042
1074
 
1043
- ## Domain verification (v1.1.22)
1075
+ ## Domain verification (v1.1.22; DNS and HTTP file v1.1.31)
1044
1076
 
1045
- `DomainVerificationComponent` (`sail-domain-verification`) is the send-code → confirm
1046
- flow for proving ownership of a domain: the domain field, the two steps, the resend
1047
- 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
1048
1080
  the same thing, so only the endpoints stay downstream.
1049
1081
 
1050
1082
  The endpoints reach it through the `DOMAIN_VERIFIER` port — sail ships no
@@ -1052,11 +1084,15 @@ implementation and knows none of the paths:
1052
1084
 
1053
1085
  ```ts
1054
1086
  export interface DomainVerifier {
1055
- requestVerification(domain: string): Observable<unknown>;
1056
- 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>;
1057
1090
  }
1058
1091
  ```
1059
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
+
1060
1096
  ```ts
1061
1097
  // app config — any service with those two methods satisfies the port
1062
1098
  providers: [{ provide: DOMAIN_VERIFIER, useExisting: RegistrationService }]
@@ -1077,12 +1113,14 @@ providers: [{ provide: DOMAIN_VERIFIER, useExisting: RegistrationService }]
1077
1113
  | `[label]` | Field label, default `Domain`. |
1078
1114
  | `[resendAfterSeconds]` | Resend cooldown, default `60`. |
1079
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. |
1080
1117
  | `(domainChange)` | Every edit, so the host form stays in sync. |
1081
1118
  | `(verified)` | Fires once with the proven domain. |
1082
1119
 
1083
1120
  Editing the domain returns the flow to `idle`: a code is bound to the domain it was
1084
1121
  issued for. The component is unstyled — style `.domain-verification`,
1085
- `.domain-verification-field`, `.domain-verification-verified` and the
1122
+ `.domain-verification-field`, `.domain-verification-verified`,
1123
+ `.domain-verification-method`, `.domain-verification-value` and the
1086
1124
  `.verification-*` hooks of the step UI it composes.
1087
1125
 
1088
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.29",
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>>(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);
@@ -43,10 +43,6 @@
43
43
  <div class="auth-error" role="alert">{{ errorMessage() }}</div>
44
44
  }
45
45
 
46
- @if (successMessage()) {
47
- <div class="auth-success" role="status">{{ successMessage() }}</div>
48
- }
49
-
50
46
  <button matButton="elevated" class="primary" type="submit" [disabled]="!chpassForm.valid">
51
47
  @if (forgotPassword()) {
52
48
  Send Confirmation Code
@@ -90,11 +90,7 @@ export class ChpassComponent extends BaseAsync {
90
90
  this.run(
91
91
  request,
92
92
  () => {
93
- if (isForgot) {
94
- this.router.navigate(['/confirm/password'], { queryParams: { username } });
95
- } else {
96
- this.successMessage.set('Password changed successfully.');
97
- }
93
+ if (isForgot) this.router.navigate(['/confirm/password'], { queryParams: { username } });
98
94
  },
99
95
  'Password change failed. Please try again.',
100
96
  );
@@ -36,10 +36,6 @@
36
36
  <div class="auth-error" role="alert">{{ errorMessage() }}</div>
37
37
  }
38
38
 
39
- @if (successMessage()) {
40
- <div class="auth-success" role="status">{{ successMessage() }}</div>
41
- }
42
-
43
39
  <button matButton="elevated" class="primary" type="submit" [disabled]="!confirmForm.valid">
44
40
  Confirm Password Change
45
41
  </button>
@@ -1,7 +1,7 @@
1
1
  import { ChangeDetectionStrategy, Component, computed, effect, inject, OnInit, ViewEncapsulation } from '@angular/core';
2
2
  import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
3
3
  import { BaseAuthService } from '../../service/auth.service';
4
- import { ActivatedRoute, Router, RouterLink } from '@angular/router';
4
+ import { ActivatedRoute, RouterLink } from '@angular/router';
5
5
  import { MatButtonModule } from '@angular/material/button';
6
6
  import { MatFormFieldModule } from '@angular/material/form-field';
7
7
  import { MatInputModule } from '@angular/material/input';
@@ -25,7 +25,6 @@ import { passwordPolicyValidator, passwordPolicyHint } from '../../util/password
25
25
  export class ConfirmChpassComponent extends BaseAsync implements OnInit {
26
26
  private auth = inject(BaseAuthService);
27
27
  private fb = inject(FormBuilder);
28
- private router = inject(Router);
29
28
  private route = inject(ActivatedRoute);
30
29
  protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
31
30
 
@@ -67,7 +66,7 @@ export class ConfirmChpassComponent extends BaseAsync implements OnInit {
67
66
 
68
67
  this.run(
69
68
  this.auth.confirmChpass(username!, code!, new_password!),
70
- () => this.router.navigate(['/login/local']),
69
+ () => {},
71
70
  'Confirmation failed. Please try again.',
72
71
  );
73
72
  }
@@ -9,6 +9,9 @@
9
9
  @if (loggedOut) {
10
10
  <p class="auth-message" role="status" aria-live="polite">You have been logged out successfully.</p>
11
11
  }
12
+ @if (passwordChanged) {
13
+ <p class="auth-message" role="status" aria-live="polite">Your password was changed. Sign in again with the new password.</p>
14
+ }
12
15
  @if (sessionHandoffError()) {
13
16
  <p class="auth-error" role="alert">{{ sessionHandoffError() }}</p>
14
17
  }
@@ -35,6 +38,10 @@
35
38
  (loginSuccess)="onSocialSuccess($event)"
36
39
  (loginError)="onSocialError($event)" />
37
40
  }
41
+ @if (guiConfig.ssoLogin) {
42
+ <hr class="auth-divider">
43
+ <sail-sso-login />
44
+ }
38
45
  <div class="auth-actions">
39
46
  <a routerLink="/login/chpass">I forgot my password</a>
40
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,13 +23,16 @@ 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 {
28
30
  private auth = inject(BaseAuthService);
29
31
  protected fb = inject(FormBuilder);
30
32
  protected readonly guiConfig: SailGuiConfig = inject(SAIL_GUI_CONFIG, {optional: true}) ?? DEFAULT_CONFIG;
31
- protected readonly loggedOut = inject(ActivatedRoute).snapshot.queryParamMap.get('loggedOut') === 'true';
33
+ private readonly queryParams = inject(ActivatedRoute).snapshot.queryParamMap;
34
+ protected readonly loggedOut = this.queryParams.get('loggedOut') === 'true';
35
+ protected readonly passwordChanged = this.queryParams.get('passwordChanged') === 'true';
32
36
  /** OAuth session hand-off failure from BaseAuthService, shown below. */
33
37
  protected readonly sessionHandoffError = this.auth.sessionHandoffError;
34
38
 
@@ -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().
@@ -89,6 +90,7 @@ export abstract class BaseAuthService extends BaseRestService {
89
90
  protected get logoutUrl() { return this.url(RestURL.logoutURL); }
90
91
  protected get registerUrl() { return this.url(RestURL.registerURL); }
91
92
  protected get chpassUrl() { return this.url(RestURL.chpassURL); }
93
+ protected get forgotPasswordUrl() { return this.url(RestURL.forgotPasswordURL); }
92
94
  protected get confirmRegisterUrl() { return this.url(RestURL.confirmRegisterURL); }
93
95
  protected get registerExchangeUrl() { return this.url(RestURL.registerExchangeURL); }
94
96
  protected get registerPartnerUrl() { return this.url(RestURL.registerPartnerURL); }
@@ -416,11 +418,12 @@ export abstract class BaseAuthService extends BaseRestService {
416
418
  }
417
419
 
418
420
  chpass(username: string, new_password: string, old_password: string) {
419
- return this.http.post<{ message: string }>(this.chpassUrl, { username, old_password, new_password });
421
+ return this.http.post<{ status: string }>(this.chpassUrl, { username, old_password, new_password })
422
+ .pipe(tap(() => this.passwordChanged()));
420
423
  }
421
424
 
422
425
  forgotPassword(username: string) {
423
- return this.http.post<{ message: string }>(this.chpassUrl, { username });
426
+ return this.http.post<{ status: string }>(this.forgotPasswordUrl, { username });
424
427
  }
425
428
 
426
429
  // ── Self-service profile (keel ProfileHandler) ──
@@ -480,7 +483,8 @@ export abstract class BaseAuthService extends BaseRestService {
480
483
  }
481
484
 
482
485
  confirmChpass(username: string, code: string, new_password: string) {
483
- return this.http.post<{ message: string }>(this.confirmChpassUrl, { username, code, new_password });
486
+ return this.http.post<{ status: string }>(this.confirmChpassUrl, { username, code, new_password })
487
+ .pipe(tap(() => this.passwordChanged()));
484
488
  }
485
489
 
486
490
  loadStoredSession() {
@@ -637,6 +641,7 @@ export abstract class BaseAuthService extends BaseRestService {
637
641
  this.appDataError.set(null);
638
642
  this.sessionHandoffError.set(null);
639
643
  this.refreshInFlight = null;
644
+ this.landing = null;
640
645
  this.sessionGeneration++; // in-flight loads and refreshes from this session are ignored
641
646
  this.appData$.next(null); // live subscribers see menus clear; identity stays stable
642
647
  this.appDataFail$.complete();
@@ -653,6 +658,11 @@ export abstract class BaseAuthService extends BaseRestService {
653
658
  this.router.navigate(['/login/local'], { queryParams: { sessionExpired: 'true' } });
654
659
  }
655
660
 
661
+ private passwordChanged(): void {
662
+ this.clearSession();
663
+ this.router.navigate(['/login/local'], { queryParams: { passwordChanged: 'true' } });
664
+ }
665
+
656
666
  private buildAuthIndex() {
657
667
  this.authIndex.clear();
658
668
  if (!this.cache?.Permissions) return;
@@ -762,14 +772,14 @@ export abstract class BaseAuthService extends BaseRestService {
762
772
  canCreate(tableName: string) { return this.checkPermission('TABLE', 'INSERT', tableName); }
763
773
  canUpdate(tableName: string) { return this.checkPermission('TABLE', 'UPDATE', tableName); }
764
774
  canDelete(tableName: string) { return this.checkPermission('TABLE', 'DELETE', tableName); }
765
- /**
766
- * Authorization check for a custom TableAction. authorityObject is the
767
- * uppercased table_name; activity is the uppercased action_name. The
768
- * grant's low_limit is checked against the table_name (lowercase) —
769
- * matching the convention populated by keel's loadTableActions.
770
- */
771
- canExecute(authorityObject: string, activity: string, tableName: string): boolean {
772
- 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);
773
783
  }
774
784
  canAccess(pageName: string) { return this.checkPermission('PAGE', 'ACCESS', pageName); }
775
785
  canReport(reportName: string) { return this.checkPermission('REPORT', 'ACCESS', reportName); }
@@ -818,12 +828,23 @@ export abstract class BaseAuthService extends BaseRestService {
818
828
  return undefined;
819
829
  }
820
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
+
821
837
  /**
822
838
  * Where to land after routes are (re)built. A fresh login starts from the
823
839
  * root or a `/login` route → go to the dashboard. A refresh / bookmark of an
824
840
  * authenticated route → preserve it so deep links survive route restoration.
825
841
  */
826
842
  private postLoginTarget(): string {
843
+ if (this.landing) {
844
+ const url = this.landing;
845
+ this.landing = null;
846
+ return url;
847
+ }
827
848
  const path = window.location.pathname;
828
849
  const preserve = !!path && path !== '/' && !path.startsWith('/login');
829
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');
@@ -8,12 +8,13 @@ export const RestURL = {
8
8
  realtimeURL: '/public/ws', // keel realtime.Hub handshake; JWT via ?token=
9
9
  loginGoogleURL: '/public/login/google',
10
10
  registerURL: '/public/register',
11
- chpassURL: '/public/login/chpass',
11
+ chpassURL: '/public/password/change', // keel revokes every refresh token on success
12
+ forgotPasswordURL: '/public/password/forgot',
12
13
  confirmRegisterURL: '/public/register/confirm',
13
14
  registerExchangeURL: '/public/register/exchange',
14
15
  registerPartnerURL: '/api/register/partner',
15
16
  plansURL: '/public/plans',
16
- confirmChpassURL: '/public/confirm/password',
17
+ confirmChpassURL: '/public/password/reset',
17
18
  twoFactorSetupURL: '/api/user/2fa/setup',
18
19
  twoFactorVerifyURL: '/api/user/2fa/verify', // authenticated — confirms setup
19
20
  twoFactorDisableURL: '/api/user/2fa/disable',
@@ -37,6 +38,9 @@ export const RestURL = {
37
38
  otpVerifyURL: '/public/otp/verify',
38
39
  otpResendURL: '/public/otp/resend',
39
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',
40
44
  logoutEverywhereURL: '/api/user/logout-everywhere',
41
45
  deleteAccountURL: '/api/user/account',
42
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
+ }