@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 +48 -10
- package/package.json +1 -1
- package/src/component/abstract/base_table.ts +10 -2
- package/src/component/login/chpass_component.html +0 -4
- package/src/component/login/chpass_component.ts +1 -5
- package/src/component/login/confirm_chpass_component.html +0 -4
- package/src/component/login/confirm_chpass_component.ts +2 -3
- package/src/component/login/login_component.html +7 -0
- package/src/component/login/login_component.ts +5 -1
- package/src/component/sso/identity_provider_configure_dialog.ts +68 -0
- package/src/component/sso/sso_login.html +8 -0
- package/src/component/sso/sso_login.ts +32 -0
- package/src/component/sso/sso_return.html +25 -0
- package/src/component/sso/sso_return.ts +82 -0
- package/src/component/table/action_params_dialog.html +38 -32
- package/src/component/table/action_params_dialog.ts +35 -7
- package/src/component/verification/domain_verification.html +79 -10
- package/src/component/verification/domain_verification.ts +56 -11
- package/src/config.ts +6 -0
- package/src/index.ts +3 -0
- package/src/service/auth.service.ts +32 -11
- package/src/service/domain_verifier.ts +22 -4
- package/src/service/rest_url.ts +6 -2
- package/src/util/sso.ts +43 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A shared Angular component library for building CRUD-based admin frontends. Provides table management, form handling, navigation, authentication, two-factor authentication, and trusted device management — all driven by metadata from a [keel](https://github.com/nauticana/keel) Go backend.
|
|
4
4
|
|
|
5
|
-
> **Compatibility:** sail and keel are versioned in lock-step. The current line is **sail v1.1.x ↔ keel v1.2.x**; sail v1.1.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`)
|
|
1046
|
-
|
|
1047
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
3
|
+
"version": "1.1.31",
|
|
4
4
|
"description": "Metadata-driven Angular component library for CRUD admin frontends backed by the keel Go backend. Tables, forms, navigation, OTP/2FA/social auth, billing, payout onboarding.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"exports": {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { computed, inject, Injector, WritableSignal } from "@angular/core";
|
|
1
|
+
import { computed, inject, Injector, Type, WritableSignal } from "@angular/core";
|
|
2
2
|
import { ForeignKey, TableAction, TableColumn, TableDefinition } from "../../model/appdata";
|
|
3
3
|
import { ConstantValue, LookupStyle, OpCode } from "../../model/common";
|
|
4
4
|
import { BaseAuthService } from "../../service/auth.service";
|
|
@@ -9,6 +9,7 @@ import { errorDetail } from "../../util/errors";
|
|
|
9
9
|
import { MatDialog } from "@angular/material/dialog";
|
|
10
10
|
import { RevealDialog } from "../table/reveal_dialog";
|
|
11
11
|
import { ActionParamsDialog } from "../table/action_params_dialog";
|
|
12
|
+
import { IdentityProviderConfigureDialog } from "../sso/identity_provider_configure_dialog";
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
/** CRUD verb used by `requireAuth()` to compose the "Missing authorization" alert. */
|
|
@@ -17,6 +18,11 @@ export type AuthVerb = 'create' | 'read' | 'update' | 'delete' | 'view';
|
|
|
17
18
|
/** ISO-like timestamp prefix — gate for comparing two strings as instants. */
|
|
18
19
|
const ISO_PREFIX = /^\d{4}-\d{2}-\d{2}T/;
|
|
19
20
|
|
|
21
|
+
/** Parameter dialogs for keel actions the generic dialog cannot lay out, keyed `table/action`. */
|
|
22
|
+
const ACTION_DIALOGS: Record<string, Type<ActionParamsDialog>> = {
|
|
23
|
+
'partner_identity_provider/configure': IdentityProviderConfigureDialog,
|
|
24
|
+
};
|
|
25
|
+
|
|
20
26
|
|
|
21
27
|
export abstract class BaseTable {
|
|
22
28
|
protected readonly cacheService = inject(BaseAuthService);
|
|
@@ -146,7 +152,9 @@ export abstract class BaseTable {
|
|
|
146
152
|
const key = action.recordSpecific && record ? this.primaryKeyValues(record) : {};
|
|
147
153
|
if (action.parameters?.length) {
|
|
148
154
|
this.#injector.get(MatDialog)
|
|
149
|
-
.open<ActionParamsDialog, TableAction, Record<string, unknown>>(
|
|
155
|
+
.open<ActionParamsDialog, TableAction, Record<string, unknown>>(
|
|
156
|
+
ACTION_DIALOGS[this.tableName() + '/' + action.action] ?? ActionParamsDialog,
|
|
157
|
+
{ data: action, width: this.dialogWidth })
|
|
150
158
|
.afterClosed()
|
|
151
159
|
.subscribe((values) => {
|
|
152
160
|
if (values) this.postAction(action, {...key, ...values}, record);
|
|
@@ -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,
|
|
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
|
-
() =>
|
|
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
|
-
|
|
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
|
-
@
|
|
9
|
-
@
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
<
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
</div>
|
|
21
|
-
}
|
|
22
|
-
@case ('select') {
|
|
23
|
-
<mat-form-field appearance="outline">
|
|
24
|
-
<mat-label>{{ f.param.caption }}</mat-label>
|
|
25
|
-
<mat-select [formControlName]="f.param.name" [required]="f.param.required">
|
|
26
|
-
@if (!f.param.required) {
|
|
27
|
-
<mat-option [value]="''">None</mat-option>
|
|
28
|
-
}
|
|
29
|
-
@for (opt of f.options; track opt.Value) {
|
|
30
|
-
<mat-option [value]="opt.Value">{{ opt.Caption }}</mat-option>
|
|
8
|
+
@if (shown(f.param.name)) {
|
|
9
|
+
@switch (f.kind) {
|
|
10
|
+
@case ('checkbox') {
|
|
11
|
+
<mat-checkbox [formControlName]="f.param.name">{{ f.param.caption }}</mat-checkbox>
|
|
12
|
+
}
|
|
13
|
+
@case ('file') {
|
|
14
|
+
<div class="action-param-file">
|
|
15
|
+
<label [for]="'action-param-' + f.param.name">{{ f.param.caption }}@if (f.param.required) { * }</label>
|
|
16
|
+
<input type="file" [id]="'action-param-' + f.param.name" [required]="f.param.required"
|
|
17
|
+
(change)="pickFile(f.param.name, $event)">
|
|
18
|
+
@if (form.controls[f.param.name].touched && form.controls[f.param.name].invalid) {
|
|
19
|
+
<p class="action-param-error" role="alert">{{ f.param.caption }} is required</p>
|
|
31
20
|
}
|
|
32
|
-
</
|
|
33
|
-
|
|
34
|
-
|
|
21
|
+
</div>
|
|
22
|
+
}
|
|
23
|
+
@case ('select') {
|
|
24
|
+
<mat-form-field appearance="outline">
|
|
25
|
+
<mat-label>{{ f.param.caption }}</mat-label>
|
|
26
|
+
<mat-select [formControlName]="f.param.name" [required]="f.param.required">
|
|
27
|
+
@if (!f.param.required) {
|
|
28
|
+
<mat-option [value]="''">None</mat-option>
|
|
29
|
+
}
|
|
30
|
+
@for (opt of f.options; track opt.Value) {
|
|
31
|
+
<mat-option [value]="opt.Value">{{ opt.Caption }}</mat-option>
|
|
32
|
+
}
|
|
33
|
+
</mat-select>
|
|
34
|
+
<mat-error>{{ f.param.caption }} is required</mat-error>
|
|
35
|
+
</mat-form-field>
|
|
36
|
+
}
|
|
37
|
+
@default {
|
|
38
|
+
<mat-form-field appearance="outline">
|
|
39
|
+
<mat-label>{{ f.param.caption }}</mat-label>
|
|
40
|
+
<input matInput [type]="f.kind" [formControlName]="f.param.name" [required]="f.param.required"
|
|
41
|
+
[attr.autocomplete]="f.kind === 'password' ? 'new-password' : null">
|
|
42
|
+
<mat-error>{{ f.param.caption }} is required</mat-error>
|
|
43
|
+
</mat-form-field>
|
|
44
|
+
}
|
|
35
45
|
}
|
|
36
|
-
@
|
|
37
|
-
<
|
|
38
|
-
<mat-label>{{ f.param.caption }}</mat-label>
|
|
39
|
-
<input matInput [type]="f.kind" [formControlName]="f.param.name" [required]="f.param.required">
|
|
40
|
-
<mat-error>{{ f.param.caption }} is required</mat-error>
|
|
41
|
-
</mat-form-field>
|
|
46
|
+
@if (hint(f.param.name); as link) {
|
|
47
|
+
<p class="action-param-hint"><a [href]="link.href" target="_blank" rel="noopener">{{ link.label }}</a></p>
|
|
42
48
|
}
|
|
43
49
|
}
|
|
44
50
|
}
|
|
@@ -10,7 +10,7 @@ import { TableAction, TableActionParameter } from "../../model/appdata";
|
|
|
10
10
|
import { ConstantValue } from "../../model/common";
|
|
11
11
|
import { BaseAuthService } from "../../service/auth.service";
|
|
12
12
|
|
|
13
|
-
type ParamKind = 'checkbox' | 'number' | 'date' | 'datetime-local' | 'file' | 'text';
|
|
13
|
+
type ParamKind = 'checkbox' | 'number' | 'date' | 'datetime-local' | 'file' | 'password' | 'text';
|
|
14
14
|
|
|
15
15
|
interface ParamField {
|
|
16
16
|
param: TableActionParameter;
|
|
@@ -20,7 +20,11 @@ interface ParamField {
|
|
|
20
20
|
options?: ConstantValue[];
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Collects a table action's declared parameters; closes with the typed values, or
|
|
25
|
+
* undefined on cancel. A subclass for one action overrides arrange, optionsFor,
|
|
26
|
+
* shown and hint.
|
|
27
|
+
*/
|
|
24
28
|
@Component({
|
|
25
29
|
selector: "sail-action-params-dialog",
|
|
26
30
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
@@ -36,12 +40,12 @@ interface ParamField {
|
|
|
36
40
|
templateUrl: "./action_params_dialog.html",
|
|
37
41
|
})
|
|
38
42
|
export class ActionParamsDialog {
|
|
39
|
-
|
|
43
|
+
protected readonly auth = inject(BaseAuthService);
|
|
40
44
|
private readonly dialogRef = inject(MatDialogRef<ActionParamsDialog, Record<string, unknown>>);
|
|
41
45
|
readonly action = inject<TableAction>(MAT_DIALOG_DATA);
|
|
42
46
|
|
|
43
|
-
readonly fields: ParamField[] = (this.action.parameters ?? []).map((param) => {
|
|
44
|
-
const options =
|
|
47
|
+
readonly fields: ParamField[] = this.arrange(this.action.parameters ?? []).map((param) => {
|
|
48
|
+
const options = this.optionsFor(param);
|
|
45
49
|
const wireKind = kindOf(param.dataType);
|
|
46
50
|
return { param, kind: options ? 'select' : wireKind, wireKind, options };
|
|
47
51
|
});
|
|
@@ -53,12 +57,13 @@ export class ActionParamsDialog {
|
|
|
53
57
|
})));
|
|
54
58
|
|
|
55
59
|
submit(): void {
|
|
56
|
-
|
|
60
|
+
const shown = this.fields.filter((f) => this.shown(f.param.name));
|
|
61
|
+
if (shown.some((f) => this.form.controls[f.param.name].invalid)) {
|
|
57
62
|
this.form.markAllAsTouched();
|
|
58
63
|
return;
|
|
59
64
|
}
|
|
60
65
|
const values: Record<string, unknown> = {};
|
|
61
|
-
for (const f of
|
|
66
|
+
for (const f of shown) {
|
|
62
67
|
const raw = this.form.controls[f.param.name].value;
|
|
63
68
|
if (raw === '' || raw == null) continue;
|
|
64
69
|
values[f.param.name] = toWire(f.wireKind, raw);
|
|
@@ -66,6 +71,26 @@ export class ActionParamsDialog {
|
|
|
66
71
|
this.dialogRef.close(values);
|
|
67
72
|
}
|
|
68
73
|
|
|
74
|
+
/** The parameters in display order. */
|
|
75
|
+
protected arrange(params: TableActionParameter[]): TableActionParameter[] {
|
|
76
|
+
return params;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Choices for a parameter; undefined renders a typed input. */
|
|
80
|
+
protected optionsFor(param: TableActionParameter): ConstantValue[] | undefined {
|
|
81
|
+
return param.lookupTable ? this.auth.getTableValues(param.lookupTable) : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Whether a parameter applies to the values entered so far; a hidden one is not posted. */
|
|
85
|
+
protected shown(_name: string): boolean {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A link shown under a parameter. */
|
|
90
|
+
protected hint(_name: string): { href: string; label: string } | null {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
69
94
|
pickFile(name: string, event: Event): void {
|
|
70
95
|
const control = this.form.controls[name];
|
|
71
96
|
control.setValue((event.target as HTMLInputElement).files?.[0] ?? '');
|
|
@@ -91,6 +116,9 @@ function kindOf(dataType: string): ParamKind {
|
|
|
91
116
|
return 'datetime-local';
|
|
92
117
|
case 'file':
|
|
93
118
|
return 'file';
|
|
119
|
+
case 'secret':
|
|
120
|
+
case 'password':
|
|
121
|
+
return 'password';
|
|
94
122
|
default:
|
|
95
123
|
return 'text';
|
|
96
124
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<div class="domain-verification" [attr.data-step]="step()">
|
|
1
|
+
<div class="domain-verification" [attr.data-step]="step()" [attr.data-method]="method()">
|
|
2
2
|
<div class="domain-verification-field">
|
|
3
3
|
<mat-form-field appearance="outline">
|
|
4
4
|
<mat-label>{{ label() }}</mat-label>
|
|
@@ -11,13 +11,82 @@
|
|
|
11
11
|
}
|
|
12
12
|
</div>
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
14
|
+
@if (methods().length > 1 && !isVerified()) {
|
|
15
|
+
<mat-form-field appearance="outline" class="domain-verification-method">
|
|
16
|
+
<mat-label>Verification method</mat-label>
|
|
17
|
+
<mat-select [value]="method()" (selectionChange)="selectMethod($event.value)" [disabled]="loading()">
|
|
18
|
+
@for (opt of methodOptions(); track opt.value) {
|
|
19
|
+
<mat-option [value]="opt.value">{{ opt.caption }}</mat-option>
|
|
20
|
+
}
|
|
21
|
+
</mat-select>
|
|
22
|
+
</mat-form-field>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@if (method() === 'EC') {
|
|
26
|
+
<sail-verification-flow
|
|
27
|
+
[step]="step()"
|
|
28
|
+
[target]="domain()"
|
|
29
|
+
[errorMessage]="errorMessage()"
|
|
30
|
+
[disabled]="loading() || !domain().trim()"
|
|
31
|
+
[resendAfterSeconds]="resendAfterSeconds()"
|
|
32
|
+
[codeExpiresInSeconds]="codeExpiresInSeconds()"
|
|
33
|
+
(requestVerification)="request()"
|
|
34
|
+
(confirmCode)="confirm($event)" />
|
|
35
|
+
} @else {
|
|
36
|
+
<div class="verification-flow domain-verification-challenge" [attr.data-step]="step()">
|
|
37
|
+
@switch (step()) {
|
|
38
|
+
@case ('verified') {
|
|
39
|
+
<p class="verification-success" role="status">Verified.</p>
|
|
40
|
+
}
|
|
41
|
+
@case ('idle') {
|
|
42
|
+
@if (errorMessage()) { <p class="verification-error" role="alert">{{ errorMessage() }}</p> }
|
|
43
|
+
<button matButton="elevated" type="button" class="primary"
|
|
44
|
+
[disabled]="loading() || !domain().trim()" (click)="issueChallenge()">
|
|
45
|
+
Start verification
|
|
46
|
+
</button>
|
|
47
|
+
}
|
|
48
|
+
@default {
|
|
49
|
+
@if (challenge(); as ch) {
|
|
50
|
+
@if (ch.recordName) {
|
|
51
|
+
<p class="verification-intro">Add this TXT record to the DNS of {{ ch.domainName }}, then confirm.</p>
|
|
52
|
+
<div class="domain-verification-value">
|
|
53
|
+
<span>Name</span><code>{{ ch.recordName }}</code>
|
|
54
|
+
<button matIconButton type="button" (click)="copy(ch.recordName)" aria-label="Copy record name">
|
|
55
|
+
<mat-icon>content_copy</mat-icon>
|
|
56
|
+
</button>
|
|
57
|
+
</div>
|
|
58
|
+
<div class="domain-verification-value">
|
|
59
|
+
<span>Value</span><code>{{ ch.recordValue }}</code>
|
|
60
|
+
<button matIconButton type="button" (click)="copy(ch.recordValue ?? '')" aria-label="Copy record value">
|
|
61
|
+
<mat-icon>content_copy</mat-icon>
|
|
62
|
+
</button>
|
|
63
|
+
</div>
|
|
64
|
+
}
|
|
65
|
+
@if (ch.fileUrl) {
|
|
66
|
+
<p class="verification-intro">Serve a file with exactly this content at this address, then confirm.</p>
|
|
67
|
+
<div class="domain-verification-value">
|
|
68
|
+
<span>Address</span><code>{{ ch.fileUrl }}</code>
|
|
69
|
+
<button matIconButton type="button" (click)="copy(ch.fileUrl)" aria-label="Copy file address">
|
|
70
|
+
<mat-icon>content_copy</mat-icon>
|
|
71
|
+
</button>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="domain-verification-value">
|
|
74
|
+
<span>Content</span><code>{{ ch.fileBody }}</code>
|
|
75
|
+
<button matIconButton type="button" (click)="copy(ch.fileBody ?? '')" aria-label="Copy file content">
|
|
76
|
+
<mat-icon>content_copy</mat-icon>
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
@if (errorMessage()) { <p class="verification-error" role="alert">{{ errorMessage() }}</p> }
|
|
82
|
+
<div class="verification-actions">
|
|
83
|
+
<button matButton="elevated" type="button" class="primary"
|
|
84
|
+
[disabled]="loading()" (click)="confirm('')">
|
|
85
|
+
@if (step() === 'verifying') { Verifying… } @else { Confirm }
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
</div>
|
|
91
|
+
}
|
|
23
92
|
</div>
|
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ChangeDetectionStrategy, Component, ViewEncapsulation,
|
|
3
|
-
computed, effect, inject, input, output, signal, untracked,
|
|
3
|
+
computed, effect, inject, input, linkedSignal, output, signal, untracked,
|
|
4
4
|
} from '@angular/core';
|
|
5
|
+
import { Clipboard } from '@angular/cdk/clipboard';
|
|
6
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
5
7
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
6
8
|
import { MatIconModule } from '@angular/material/icon';
|
|
7
9
|
import { MatInputModule } from '@angular/material/input';
|
|
10
|
+
import { MatSelectModule } from '@angular/material/select';
|
|
8
11
|
import { VerificationStep } from '../../model/dashboard';
|
|
9
|
-
import {
|
|
12
|
+
import { BaseAuthService } from '../../service/auth.service';
|
|
13
|
+
import { DOMAIN_VERIFIER, DomainChallenge, DomainVerificationMethod } from '../../service/domain_verifier';
|
|
10
14
|
import { BaseAsync } from '../abstract/base_async';
|
|
11
15
|
import { VerificationFlowComponent } from '../dashboard/verification_flow';
|
|
12
16
|
|
|
13
17
|
/**
|
|
14
|
-
* Domain-ownership verification: the domain field plus the
|
|
15
|
-
*
|
|
16
|
-
*
|
|
18
|
+
* Domain-ownership verification: the domain field plus the steps of the chosen method
|
|
19
|
+
* (email code, DNS TXT record or HTTP file), driven against the injected
|
|
20
|
+
* `DOMAIN_VERIFIER`. The app owns the endpoints, the page shell and the styling;
|
|
21
|
+
* `verified` fires once with the domain that was proven.
|
|
17
22
|
*
|
|
18
23
|
* Selector: <sail-domain-verification>
|
|
19
24
|
*/
|
|
@@ -22,15 +27,19 @@ import { VerificationFlowComponent } from '../dashboard/verification_flow';
|
|
|
22
27
|
templateUrl: './domain_verification.html',
|
|
23
28
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
24
29
|
encapsulation: ViewEncapsulation.None,
|
|
25
|
-
imports: [MatFormFieldModule, MatIconModule, MatInputModule, VerificationFlowComponent],
|
|
30
|
+
imports: [MatButtonModule, MatFormFieldModule, MatIconModule, MatInputModule, MatSelectModule, VerificationFlowComponent],
|
|
26
31
|
})
|
|
27
32
|
export class DomainVerificationComponent extends BaseAsync {
|
|
28
33
|
private readonly verifier = inject(DOMAIN_VERIFIER);
|
|
34
|
+
private readonly auth = inject(BaseAuthService, { optional: true });
|
|
35
|
+
private readonly clipboard = inject(Clipboard);
|
|
29
36
|
|
|
30
37
|
readonly domainInput = input('', { alias: 'domain' });
|
|
31
38
|
readonly label = input('Domain');
|
|
32
39
|
readonly resendAfterSeconds = input(60);
|
|
33
40
|
readonly codeExpiresInSeconds = input(0);
|
|
41
|
+
/** Methods offered, first is the default; DT and HF need `DomainVerifier.challenge`. */
|
|
42
|
+
readonly methodsInput = input<DomainVerificationMethod[]>(['EC'], { alias: 'methods' });
|
|
34
43
|
|
|
35
44
|
readonly domainChange = output<string>();
|
|
36
45
|
readonly verified = output<string>();
|
|
@@ -38,6 +47,14 @@ export class DomainVerificationComponent extends BaseAsync {
|
|
|
38
47
|
readonly domain = signal('');
|
|
39
48
|
readonly step = signal<VerificationStep>('idle');
|
|
40
49
|
readonly isVerified = computed(() => this.step() === 'verified');
|
|
50
|
+
readonly methods = computed(() => this.methodsInput().filter((m) => m === 'EC' || !!this.verifier.challenge));
|
|
51
|
+
readonly method = linkedSignal<DomainVerificationMethod>(() => this.methods()[0] ?? 'EC');
|
|
52
|
+
readonly challenge = signal<DomainChallenge | null>(null);
|
|
53
|
+
/** Captions from keel's domain_verification_method catalog; the code when it is not loaded. */
|
|
54
|
+
protected readonly methodOptions = computed(() => {
|
|
55
|
+
const captions = this.auth?.getDomainValues('domain_verification_method') ?? [];
|
|
56
|
+
return this.methods().map((value) => ({ value, caption: captions.find((c) => c.Value === value)?.Caption ?? value }));
|
|
57
|
+
});
|
|
41
58
|
|
|
42
59
|
constructor() {
|
|
43
60
|
super();
|
|
@@ -45,18 +62,45 @@ export class DomainVerificationComponent extends BaseAsync {
|
|
|
45
62
|
const value = this.domainInput();
|
|
46
63
|
if (value === untracked(this.domain)) return;
|
|
47
64
|
this.domain.set(value);
|
|
48
|
-
this.
|
|
49
|
-
this.clearMessages();
|
|
65
|
+
this.reset();
|
|
50
66
|
});
|
|
51
67
|
}
|
|
52
68
|
|
|
53
69
|
protected onDomainInput(value: string): void {
|
|
54
70
|
this.domain.set(value);
|
|
55
|
-
this.
|
|
56
|
-
this.clearMessages();
|
|
71
|
+
this.reset(); // a code or challenge is bound to the domain it was issued for
|
|
57
72
|
this.domainChange.emit(value);
|
|
58
73
|
}
|
|
59
74
|
|
|
75
|
+
protected selectMethod(method: DomainVerificationMethod): void {
|
|
76
|
+
this.method.set(method);
|
|
77
|
+
this.reset();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
protected issueChallenge(): void {
|
|
81
|
+
const domain = this.domain().trim();
|
|
82
|
+
const method = this.method();
|
|
83
|
+
if (!domain || method === 'EC' || !this.verifier.challenge || this.isVerified() || this.loading()) return;
|
|
84
|
+
this.run(
|
|
85
|
+
this.verifier.challenge(domain, method),
|
|
86
|
+
(challenge) => {
|
|
87
|
+
this.challenge.set(challenge);
|
|
88
|
+
this.step.set('requested');
|
|
89
|
+
},
|
|
90
|
+
'Could not start the verification.',
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
protected copy(value: string): void {
|
|
95
|
+
this.clipboard.copy(value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private reset(): void {
|
|
99
|
+
this.step.set('idle');
|
|
100
|
+
this.challenge.set(null);
|
|
101
|
+
this.clearMessages();
|
|
102
|
+
}
|
|
103
|
+
|
|
60
104
|
protected request(): void {
|
|
61
105
|
const domain = this.domain().trim();
|
|
62
106
|
if (!domain || this.isVerified() || this.loading()) return;
|
|
@@ -68,12 +112,13 @@ export class DomainVerificationComponent extends BaseAsync {
|
|
|
68
112
|
);
|
|
69
113
|
}
|
|
70
114
|
|
|
115
|
+
/** Confirms the email code, or the published challenge when `code` is empty. */
|
|
71
116
|
protected confirm(code: string): void {
|
|
72
117
|
const domain = this.domain().trim();
|
|
73
118
|
if (!domain || this.isVerified() || this.loading()) return;
|
|
74
119
|
this.step.set('verifying');
|
|
75
120
|
this.run(
|
|
76
|
-
this.verifier.confirm(domain, code),
|
|
121
|
+
this.verifier.confirm(domain, code, this.method()),
|
|
77
122
|
() => {
|
|
78
123
|
this.step.set('verified');
|
|
79
124
|
this.verified.emit(domain);
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { InjectionToken, Type } from '@angular/core';
|
|
2
2
|
import { Routes } from '@angular/router';
|
|
3
3
|
import { ApplicationData } from './model/appdata';
|
|
4
|
+
import { SsoErrorCode } from './util/sso';
|
|
4
5
|
|
|
5
6
|
export interface RouteLink {
|
|
6
7
|
label: string;
|
|
@@ -75,6 +76,11 @@ export interface SailGuiConfig {
|
|
|
75
76
|
* e.g. ['mcp.example.com']. Anything not listed is ignored. */
|
|
76
77
|
allowedReturnHosts?: string[];
|
|
77
78
|
|
|
79
|
+
/** Shows `<sail-sso-login>` (organization sign-in) on `<sail-login>`. Off by default. */
|
|
80
|
+
ssoLogin?: boolean;
|
|
81
|
+
/** Replaces the default wording of single sign-on error codes. */
|
|
82
|
+
ssoErrorMessages?: Partial<Record<SsoErrorCode, string>>;
|
|
83
|
+
|
|
78
84
|
/** Public route serving the password policy (keel PublicHandler.GetPasswordPolicy),
|
|
79
85
|
* e.g. '/public/v1/auth/password/policy'. When set, sail fetches it once and
|
|
80
86
|
* password inputs validate against it. Omitted → policy validation is skipped. */
|
package/src/index.ts
CHANGED
|
@@ -45,6 +45,8 @@ export * from './component/login/sms_consent';
|
|
|
45
45
|
export * from './component/login/otp_input';
|
|
46
46
|
export * from './component/login/social_login';
|
|
47
47
|
export * from './component/login/account_deletion';
|
|
48
|
+
export * from './component/sso/sso_login';
|
|
49
|
+
export * from './component/sso/sso_return';
|
|
48
50
|
export * from './component/account/profile_editor';
|
|
49
51
|
export * from './component/account/my_account';
|
|
50
52
|
export * from './component/security/twofactor_setup';
|
|
@@ -96,3 +98,4 @@ export * from './util/text';
|
|
|
96
98
|
export * from './util/money';
|
|
97
99
|
export * from './util/errors';
|
|
98
100
|
export * from './util/password_policy';
|
|
101
|
+
export * from './util/sso';
|
|
@@ -80,6 +80,7 @@ export abstract class BaseAuthService extends BaseRestService {
|
|
|
80
80
|
/** The route config active before the first initRoutes() — restored on logout
|
|
81
81
|
* so the login screen doesn't keep the previous user's route tree. */
|
|
82
82
|
private preLoginRoutes: Routes | null = null;
|
|
83
|
+
private landing: string | null = null;
|
|
83
84
|
|
|
84
85
|
// Lazy getters, not field initializers: fields would snapshot during super(),
|
|
85
86
|
// before a subclass constructor can call configureRestUrls().
|
|
@@ -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<{
|
|
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<{
|
|
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<{
|
|
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
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
canExecute(authorityObject: string, activity: string,
|
|
772
|
-
return this.
|
|
775
|
+
/** Whether a grant's low_limit pattern matches value. */
|
|
776
|
+
hasPermission(authorityObject: string, activity: string, value: string): boolean {
|
|
777
|
+
return this.checkPermission(authorityObject, activity, value);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** Authorization check for a custom TableAction. */
|
|
781
|
+
canExecute(authorityObject: string, activity: string, value: string): boolean {
|
|
782
|
+
return this.hasPermission(authorityObject, activity, value);
|
|
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
|
|
20
|
+
* Port for proving ownership of a domain.
|
|
6
21
|
*
|
|
7
22
|
* sail ships no implementation — the endpoints, their paths and their payloads are
|
|
8
23
|
* the app's. Register one at composition time:
|
|
9
24
|
*
|
|
10
25
|
* { provide: DOMAIN_VERIFIER, useExisting: RegistrationService }
|
|
11
26
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
27
|
+
* `requestVerification` sends an email code. `challenge` issues a DNS TXT or HTTP
|
|
28
|
+
* file challenge; implement it to offer those methods. `confirm` receives the
|
|
29
|
+
* method for a challenge and an empty code. `DomainVerificationComponent` reads only
|
|
30
|
+
* success or failure of the other calls, and reports a failure through `errorDetail()`.
|
|
14
31
|
*/
|
|
15
32
|
export interface DomainVerifier {
|
|
16
33
|
requestVerification(domain: string): Observable<unknown>;
|
|
17
|
-
|
|
34
|
+
challenge?(domain: string, method: Exclude<DomainVerificationMethod, 'EC'>): Observable<DomainChallenge>;
|
|
35
|
+
confirm(domain: string, code: string, method?: DomainVerificationMethod): Observable<unknown>;
|
|
18
36
|
}
|
|
19
37
|
|
|
20
38
|
export const DOMAIN_VERIFIER = new InjectionToken<DomainVerifier>('SailDomainVerifier');
|
package/src/service/rest_url.ts
CHANGED
|
@@ -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/
|
|
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/
|
|
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
|
package/src/util/sso.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { RestURL } from '../service/rest_url';
|
|
2
|
+
|
|
3
|
+
/** Error codes keel's SSOHandler sends back to the return URL as `?error=`. */
|
|
4
|
+
export type SsoErrorCode =
|
|
5
|
+
| 'sso_unavailable'
|
|
6
|
+
| 'sso_failed'
|
|
7
|
+
| 'mfa_required'
|
|
8
|
+
| 'sso_email_not_allowed'
|
|
9
|
+
| 'sso_no_account'
|
|
10
|
+
| 'sso_other_partner'
|
|
11
|
+
| 'sso_required'
|
|
12
|
+
| 'account_unavailable'
|
|
13
|
+
| 'identity_linked'
|
|
14
|
+
| 'sso_test_mismatch'
|
|
15
|
+
| 'domain_not_proven'
|
|
16
|
+
| 'server_error';
|
|
17
|
+
|
|
18
|
+
/** Default wording; an app overrides entries through `SailGuiConfig.ssoErrorMessages`. */
|
|
19
|
+
export const SSO_ERROR_MESSAGES: Record<SsoErrorCode, string> = {
|
|
20
|
+
sso_unavailable: 'Single sign-on is not available for this email address. Use another sign-in method.',
|
|
21
|
+
sso_failed: 'The sign-in could not be completed. Please try again.',
|
|
22
|
+
mfa_required: 'Your organization requires multi-factor authentication. Complete it at your organization\'s sign-in page and try again.',
|
|
23
|
+
sso_email_not_allowed: 'This account is outside your organization\'s verified domains.',
|
|
24
|
+
sso_no_account: 'You do not have an account yet. Ask your organization\'s administrator for access.',
|
|
25
|
+
sso_other_partner: 'This account belongs to another organization.',
|
|
26
|
+
sso_required: 'Your organization requires you to sign in with single sign-on.',
|
|
27
|
+
account_unavailable: 'This account is locked or expired. Contact your administrator.',
|
|
28
|
+
identity_linked: 'This account is already linked to a different identity at your organization\'s identity provider.',
|
|
29
|
+
sso_test_mismatch: 'The identity provider signed in someone other than you. Sign in there as yourself and test again.',
|
|
30
|
+
domain_not_proven: 'The identity provider did not prove your organization\'s domain.',
|
|
31
|
+
server_error: 'Something went wrong. Please try again.',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** The message for a returned code; an unknown code reads as `server_error`. */
|
|
35
|
+
export function ssoErrorMessage(code: string | null | undefined, overrides?: Partial<Record<SsoErrorCode, string>>): string {
|
|
36
|
+
const key = (code && code in SSO_ERROR_MESSAGES ? code : 'server_error') as SsoErrorCode;
|
|
37
|
+
return overrides?.[key] ?? SSO_ERROR_MESSAGES[key];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** keel's `/public/sso/start` for an address, on the API host. */
|
|
41
|
+
export function ssoStartUrl(email: string): string {
|
|
42
|
+
return RestURL.httpHost + RestURL.ssoStartURL + '?' + new URLSearchParams({ email: email.trim() }).toString();
|
|
43
|
+
}
|