@medalsocial/booking 0.0.0-placeholder.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +220 -1
- package/dist/core/age.d.mts +34 -0
- package/dist/core/age.mjs +95 -0
- package/dist/core/age.mjs.map +1 -0
- package/dist/core/attempt-store.d.mts +111 -0
- package/dist/core/attempt-store.mjs +159 -0
- package/dist/core/attempt-store.mjs.map +1 -0
- package/dist/core/categories.d.mts +27 -0
- package/dist/core/categories.mjs +42 -0
- package/dist/core/categories.mjs.map +1 -0
- package/dist/core/clock.d.mts +33 -0
- package/dist/core/clock.mjs +251 -0
- package/dist/core/clock.mjs.map +1 -0
- package/dist/core/config.d.mts +178 -0
- package/dist/core/config.mjs +394 -0
- package/dist/core/config.mjs.map +1 -0
- package/dist/core/consent.d.mts +14 -0
- package/dist/core/consent.mjs +17 -0
- package/dist/core/consent.mjs.map +1 -0
- package/dist/core/deep-link.d.mts +40 -0
- package/dist/core/deep-link.mjs +160 -0
- package/dist/core/deep-link.mjs.map +1 -0
- package/dist/core/display-name.d.mts +18 -0
- package/dist/core/display-name.mjs +30 -0
- package/dist/core/display-name.mjs.map +1 -0
- package/dist/core/draft-store.d.mts +89 -0
- package/dist/core/draft-store.mjs +131 -0
- package/dist/core/draft-store.mjs.map +1 -0
- package/dist/core/dto.d.mts +46 -0
- package/dist/core/dto.mjs +157 -0
- package/dist/core/dto.mjs.map +1 -0
- package/dist/core/ics.d.mts +63 -0
- package/dist/core/ics.mjs +130 -0
- package/dist/core/ics.mjs.map +1 -0
- package/dist/core/index.d.mts +35 -0
- package/dist/core/index.mjs +32 -0
- package/dist/core/labels.d.mts +88 -0
- package/dist/core/labels.mjs +93 -0
- package/dist/core/labels.mjs.map +1 -0
- package/dist/core/machine.d.mts +645 -0
- package/dist/core/machine.mjs +988 -0
- package/dist/core/machine.mjs.map +1 -0
- package/dist/core/money.d.mts +12 -0
- package/dist/core/money.mjs +51 -0
- package/dist/core/money.mjs.map +1 -0
- package/dist/core/next-available.d.mts +20 -0
- package/dist/core/next-available.mjs +36 -0
- package/dist/core/next-available.mjs.map +1 -0
- package/dist/core/next-free.d.mts +25 -0
- package/dist/core/next-free.mjs +44 -0
- package/dist/core/next-free.mjs.map +1 -0
- package/dist/core/party-slots.d.mts +79 -0
- package/dist/core/party-slots.mjs +229 -0
- package/dist/core/party-slots.mjs.map +1 -0
- package/dist/core/paths.d.mts +11 -0
- package/dist/core/paths.mjs +56 -0
- package/dist/core/paths.mjs.map +1 -0
- package/dist/core/phone.d.mts +17 -0
- package/dist/core/phone.mjs +58 -0
- package/dist/core/phone.mjs.map +1 -0
- package/dist/core/portal/cookies.d.mts +29 -0
- package/dist/core/portal/dto.d.mts +140 -0
- package/dist/core/portal/dto.mjs +142 -0
- package/dist/core/portal/dto.mjs.map +1 -0
- package/dist/core/portal/family.d.mts +39 -0
- package/dist/core/portal/family.mjs +39 -0
- package/dist/core/portal/family.mjs.map +1 -0
- package/dist/core/portal/login-input.d.mts +29 -0
- package/dist/core/portal/login-input.mjs +29 -0
- package/dist/core/portal/login-input.mjs.map +1 -0
- package/dist/core/portal/next-path.d.mts +12 -0
- package/dist/core/portal/next-path.mjs +98 -0
- package/dist/core/portal/next-path.mjs.map +1 -0
- package/dist/core/portal/return-path.d.mts +7 -0
- package/dist/core/portal/return-path.mjs +49 -0
- package/dist/core/portal/return-path.mjs.map +1 -0
- package/dist/core/portal/session-cookie.d.mts +27 -0
- package/dist/core/portal/session-cookie.mjs +30 -0
- package/dist/core/portal/session-cookie.mjs.map +1 -0
- package/dist/core/portal/vipps-link.d.mts +56 -0
- package/dist/core/portal/vipps-link.mjs +136 -0
- package/dist/core/portal/vipps-link.mjs.map +1 -0
- package/dist/core/portal/vipps-return.d.mts +39 -0
- package/dist/core/portal/vipps-return.mjs +69 -0
- package/dist/core/portal/vipps-return.mjs.map +1 -0
- package/dist/core/rebook-store.d.mts +28 -0
- package/dist/core/rebook-store.mjs +60 -0
- package/dist/core/rebook-store.mjs.map +1 -0
- package/dist/core/restore-gate.d.mts +40 -0
- package/dist/core/restore-gate.mjs +96 -0
- package/dist/core/restore-gate.mjs.map +1 -0
- package/dist/core/stores.d.mts +8 -0
- package/dist/core/stores.mjs +23 -0
- package/dist/core/stores.mjs.map +1 -0
- package/dist/core/types.d.mts +245 -0
- package/dist/core/visit.d.mts +26 -0
- package/dist/core/visit.mjs +40 -0
- package/dist/core/visit.mjs.map +1 -0
- package/dist/core/wire.d.mts +37 -0
- package/dist/next/cache/memory.d.mts +14 -0
- package/dist/next/cache/memory.mjs +51 -0
- package/dist/next/cache/memory.mjs.map +1 -0
- package/dist/next/cache/next-data.d.mts +6 -0
- package/dist/next/cache/next-data.mjs +35 -0
- package/dist/next/cache/next-data.mjs.map +1 -0
- package/dist/next/cache/noop.d.mts +6 -0
- package/dist/next/cache/noop.mjs +15 -0
- package/dist/next/cache/noop.mjs.map +1 -0
- package/dist/next/cache/workers.d.mts +29 -0
- package/dist/next/cache/workers.mjs +74 -0
- package/dist/next/cache/workers.mjs.map +1 -0
- package/dist/next/catalogue.d.mts +38 -0
- package/dist/next/catalogue.mjs +215 -0
- package/dist/next/catalogue.mjs.map +1 -0
- package/dist/next/handler.d.mts +11 -0
- package/dist/next/handler.mjs +150 -0
- package/dist/next/handler.mjs.map +1 -0
- package/dist/next/index.d.mts +17 -0
- package/dist/next/index.mjs +14 -0
- package/dist/next/loaders.d.mts +79 -0
- package/dist/next/loaders.mjs +195 -0
- package/dist/next/loaders.mjs.map +1 -0
- package/dist/next/manage-dto.d.mts +6 -0
- package/dist/next/manage-dto.mjs +28 -0
- package/dist/next/manage-dto.mjs.map +1 -0
- package/dist/next/medal.d.mts +105 -0
- package/dist/next/medal.mjs +155 -0
- package/dist/next/medal.mjs.map +1 -0
- package/dist/next/options.d.mts +158 -0
- package/dist/next/options.mjs +65 -0
- package/dist/next/options.mjs.map +1 -0
- package/dist/next/portal/guardian.d.mts +42 -0
- package/dist/next/portal/guardian.mjs +76 -0
- package/dist/next/portal/guardian.mjs.map +1 -0
- package/dist/next/portal/medal-portal.d.mts +161 -0
- package/dist/next/portal/medal-portal.mjs +825 -0
- package/dist/next/portal/medal-portal.mjs.map +1 -0
- package/dist/next/portal/session.d.mts +38 -0
- package/dist/next/portal/session.mjs +40 -0
- package/dist/next/portal/session.mjs.map +1 -0
- package/dist/next/portal/vipps-flash.d.mts +14 -0
- package/dist/next/portal/vipps-flash.mjs +41 -0
- package/dist/next/portal/vipps-flash.mjs.map +1 -0
- package/dist/next/portal.d.mts +205 -0
- package/dist/next/portal.mjs +469 -0
- package/dist/next/portal.mjs.map +1 -0
- package/dist/next/request.d.mts +51 -0
- package/dist/next/request.mjs +123 -0
- package/dist/next/request.mjs.map +1 -0
- package/dist/next/routes/catalogue-routes.mjs +344 -0
- package/dist/next/routes/catalogue-routes.mjs.map +1 -0
- package/dist/next/routes/create.mjs +460 -0
- package/dist/next/routes/create.mjs.map +1 -0
- package/dist/next/routes/manage.mjs +191 -0
- package/dist/next/routes/manage.mjs.map +1 -0
- package/dist/next/routes/portal-routes.mjs +312 -0
- package/dist/next/routes/portal-routes.mjs.map +1 -0
- package/dist/next/routes/shared.mjs +162 -0
- package/dist/next/routes/shared.mjs.map +1 -0
- package/dist/next/routes/vipps-return.mjs +166 -0
- package/dist/next/routes/vipps-return.mjs.map +1 -0
- package/dist/next/runtime.d.mts +49 -0
- package/dist/next/runtime.mjs +94 -0
- package/dist/next/runtime.mjs.map +1 -0
- package/dist/next/seed.d.mts +71 -0
- package/dist/next/seed.mjs +495 -0
- package/dist/next/seed.mjs.map +1 -0
- package/dist/next/server.d.mts +26 -0
- package/dist/next/server.mjs +63 -0
- package/dist/next/server.mjs.map +1 -0
- package/dist/react/BookingLink.d.mts +61 -0
- package/dist/react/BookingLink.mjs +102 -0
- package/dist/react/BookingLink.mjs.map +1 -0
- package/dist/react/BookingWizard.d.mts +49 -0
- package/dist/react/BookingWizard.mjs +598 -0
- package/dist/react/BookingWizard.mjs.map +1 -0
- package/dist/react/LoginPageQuery.d.mts +37 -0
- package/dist/react/LoginPageQuery.mjs +106 -0
- package/dist/react/LoginPageQuery.mjs.map +1 -0
- package/dist/react/LoginSheet.d.mts +49 -0
- package/dist/react/LoginSheet.mjs +98 -0
- package/dist/react/LoginSheet.mjs.map +1 -0
- package/dist/react/ManageBooking.d.mts +50 -0
- package/dist/react/ManageBooking.mjs +197 -0
- package/dist/react/ManageBooking.mjs.map +1 -0
- package/dist/react/PortalDashboard.d.mts +10 -0
- package/dist/react/Provider.d.mts +82 -0
- package/dist/react/Provider.mjs +132 -0
- package/dist/react/Provider.mjs.map +1 -0
- package/dist/react/actions.d.mts +133 -0
- package/dist/react/actions.mjs +90 -0
- package/dist/react/actions.mjs.map +1 -0
- package/dist/react/index.d.mts +28 -0
- package/dist/react/index.mjs +20 -0
- package/dist/react/kit.d.mts +54 -0
- package/dist/react/kit.mjs +127 -0
- package/dist/react/kit.mjs.map +1 -0
- package/dist/react/labels/index.d.mts +23 -0
- package/dist/react/labels/index.mjs +44 -0
- package/dist/react/labels/index.mjs.map +1 -0
- package/dist/react/labels/login.d.mts +10 -0
- package/dist/react/labels/login.mjs +13 -0
- package/dist/react/labels/login.mjs.map +1 -0
- package/dist/react/labels/manage.d.mts +15 -0
- package/dist/react/labels/manage.mjs +19 -0
- package/dist/react/labels/manage.mjs.map +1 -0
- package/dist/react/labels/portal.d.mts +25 -0
- package/dist/react/labels/portal.mjs +41 -0
- package/dist/react/labels/portal.mjs.map +1 -0
- package/dist/react/labels/screens.d.mts +11 -0
- package/dist/react/labels/screens.mjs +721 -0
- package/dist/react/labels/screens.mjs.map +1 -0
- package/dist/react/labels/wizard.d.mts +76 -0
- package/dist/react/labels/wizard.mjs +97 -0
- package/dist/react/labels/wizard.mjs.map +1 -0
- package/dist/react/link/index.d.mts +5 -0
- package/dist/react/link/index.mjs +5 -0
- package/dist/react/portal/SessionTouch.d.mts +19 -0
- package/dist/react/portal/SessionTouch.mjs +40 -0
- package/dist/react/portal/SessionTouch.mjs.map +1 -0
- package/dist/react/portal/age-prompt.d.mts +10 -0
- package/dist/react/portal/age-prompt.mjs +21 -0
- package/dist/react/portal/age-prompt.mjs.map +1 -0
- package/dist/react/portal/book-again.d.mts +18 -0
- package/dist/react/portal/book-again.mjs +21 -0
- package/dist/react/portal/book-again.mjs.map +1 -0
- package/dist/react/portal/child-summary.d.mts +17 -0
- package/dist/react/portal/child-summary.mjs +45 -0
- package/dist/react/portal/child-summary.mjs.map +1 -0
- package/dist/react/portal/dashboard.d.mts +74 -0
- package/dist/react/portal/dashboard.mjs +204 -0
- package/dist/react/portal/dashboard.mjs.map +1 -0
- package/dist/react/portal/leave.d.mts +17 -0
- package/dist/react/portal/leave.mjs +21 -0
- package/dist/react/portal/leave.mjs.map +1 -0
- package/dist/react/portal/sections.d.mts +12 -0
- package/dist/react/portal/sections.mjs +48 -0
- package/dist/react/portal/sections.mjs.map +1 -0
- package/dist/react/portal/tabs.d.mts +23 -0
- package/dist/react/portal/tabs.mjs +36 -0
- package/dist/react/portal/tabs.mjs.map +1 -0
- package/dist/react/portal/vipps-flash.d.mts +11 -0
- package/dist/react/portal/vipps-flash.mjs +18 -0
- package/dist/react/portal/vipps-flash.mjs.map +1 -0
- package/dist/react/portal/wired.mjs +202 -0
- package/dist/react/portal/wired.mjs.map +1 -0
- package/dist/react/screen-labels.mjs +14 -0
- package/dist/react/screen-labels.mjs.map +1 -0
- package/dist/react/shared.d.mts +13 -0
- package/dist/react/shared.mjs +7 -0
- package/dist/react/use-next-free.d.mts +19 -0
- package/dist/react/use-next-free.mjs +56 -0
- package/dist/react/use-next-free.mjs.map +1 -0
- package/dist/react/useBooking.d.mts +179 -0
- package/dist/react/useBooking.mjs +1331 -0
- package/dist/react/useBooking.mjs.map +1 -0
- package/dist/react/wizard/adapters.mjs +189 -0
- package/dist/react/wizard/adapters.mjs.map +1 -0
- package/dist/react/wizard/index.d.mts +11 -0
- package/dist/react/wizard/index.mjs +6 -0
- package/dist/react/wizard-error.mjs +29 -0
- package/dist/react/wizard-error.mjs.map +1 -0
- package/package.json +126 -5
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
import { createPortalDto, toPortalProfileDto } from "../../core/portal/dto.mjs";
|
|
2
|
+
import { DEFAULT_PORTAL_MESSAGES } from "../options.mjs";
|
|
3
|
+
import { unwrap } from "../medal.mjs";
|
|
4
|
+
import { MedalApiError } from "@medalsocial/sdk";
|
|
5
|
+
//#region src/next/portal/medal-portal.ts
|
|
6
|
+
/**
|
|
7
|
+
* The portal seam: everything `/min-side` asks Medal, in this site's own terms.
|
|
8
|
+
*
|
|
9
|
+
* The sibling of `lib/booking/medal-client.ts`, and built on the same SDK. The
|
|
10
|
+
* difference is the credential. The booking seam carries a per-appointment
|
|
11
|
+
* manage token on a handful of calls; every call here except the two login
|
|
12
|
+
* steps carries the parent's SESSION — a bearer credential for their whole
|
|
13
|
+
* account, read from the session cookie by `portal/session.ts` and
|
|
14
|
+
* handed in as a plain string. This module is the last place that string is
|
|
15
|
+
* known by name before it goes on the wire, which makes it the last place two
|
|
16
|
+
* things can be guaranteed:
|
|
17
|
+
*
|
|
18
|
+
* 1. **It is not in any error that leaves here.** The SDK formats the failing
|
|
19
|
+
* request into its messages, and the session travels in a header of that
|
|
20
|
+
* request; whatever an action lets escape is logged verbatim by
|
|
21
|
+
* `next-safe-action`'s error handler and a log is copied to places a cookie
|
|
22
|
+
* is not. `withSession` scrubs the value out of the message and stack of
|
|
23
|
+
* anything it rethrows — the same instance, so `instanceof`, `status` and
|
|
24
|
+
* `code` still hold for whoever catches it.
|
|
25
|
+
* 2. **It is never logged here.** There is no `logger` import in this file on
|
|
26
|
+
* purpose. The one thing a log line about a portal failure must not contain
|
|
27
|
+
* is the thing every function in this file holds.
|
|
28
|
+
*
|
|
29
|
+
* Two errors are this module's own vocabulary, because two upstream answers
|
|
30
|
+
* are decisions for the page rather than failures: `PortalSessionExpiredError`
|
|
31
|
+
* (Medal no longer honours the cookie — clear it and send the parent back to
|
|
32
|
+
* the login) and `PortalThrottledError` (Medal is rate-limiting this address —
|
|
33
|
+
* the login copy already says «if we have your address, a code is on its way»,
|
|
34
|
+
* and a throttle must not become an oracle for which addresses exist).
|
|
35
|
+
* `PortalValidationError` carries the engine's message for a rejected profile
|
|
36
|
+
* edit; it is written for integrators and quotes wire field names, so the form
|
|
37
|
+
* should prefer its own copy and fall back to this.
|
|
38
|
+
*/
|
|
39
|
+
/** Medal answered 401 with a `PORTAL_SESSION_*` code: the cookie is dead. */
|
|
40
|
+
var PortalSessionExpiredError = class extends Error {
|
|
41
|
+
constructor() {
|
|
42
|
+
super("portal session expired");
|
|
43
|
+
this.name = "PortalSessionExpiredError";
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
/** Medal answered 429: this address has asked for too many codes. */
|
|
47
|
+
var PortalThrottledError = class extends Error {
|
|
48
|
+
constructor() {
|
|
49
|
+
super("portal request throttled");
|
|
50
|
+
this.name = "PortalThrottledError";
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
/** Medal rejected a profile edit; `message` is the engine's own sentence. */
|
|
54
|
+
var PortalValidationError = class extends Error {
|
|
55
|
+
constructor(message) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.name = "PortalValidationError";
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Medal answered 503 `VIPPS_NOT_CONFIGURED`: this workspace has no Vipps
|
|
62
|
+
* credentials (yet). A decision for the login page — show the e-mail form
|
|
63
|
+
* and say so — not a failure.
|
|
64
|
+
*/
|
|
65
|
+
var PortalVippsUnavailableError = class extends Error {
|
|
66
|
+
constructor() {
|
|
67
|
+
super("vipps login unavailable");
|
|
68
|
+
this.name = "PortalVippsUnavailableError";
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Medal answered 409 `VIPPS_IDENTITY_CONFLICT` to a pending-link code: the code
|
|
73
|
+
* was right, but this Vipps account has since been bound to another customer.
|
|
74
|
+
* An answer for the login sheet — «log in with e-mail, or ring the salon».
|
|
75
|
+
*/
|
|
76
|
+
var PortalVippsConflictError = class extends Error {
|
|
77
|
+
constructor() {
|
|
78
|
+
super("vipps identity conflict");
|
|
79
|
+
this.name = "PortalVippsConflictError";
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
/** The portal seam over one Medal seam. */
|
|
83
|
+
function createPortalSeam(medal, options) {
|
|
84
|
+
const messages = {
|
|
85
|
+
...DEFAULT_PORTAL_MESSAGES,
|
|
86
|
+
...options.messages
|
|
87
|
+
};
|
|
88
|
+
const { toPortalBookingDto } = createPortalDto(options.config);
|
|
89
|
+
/**
|
|
90
|
+
* The SDK's portal namespace (`Medal#portal`, SDK ≥ 1.8.0). `requireMedal()`
|
|
91
|
+
* throws `MedalConfigError` for a missing key, and that is left to propagate:
|
|
92
|
+
* a portal that silently cannot reach Medal is worse than one that fails
|
|
93
|
+
* visibly.
|
|
94
|
+
*/
|
|
95
|
+
function portalClient() {
|
|
96
|
+
return medal.requireMedal().portal;
|
|
97
|
+
}
|
|
98
|
+
const REDACTED = "<session>";
|
|
99
|
+
/**
|
|
100
|
+
* How far below the thrown error the scrub follows `cause` and
|
|
101
|
+
* `AggregateError.errors`. Three is deeper than any chain the SDK or `fetch`
|
|
102
|
+
* builds (undici wraps one `cause` under `fetch failed`), and a bound is
|
|
103
|
+
* needed at all because `cause` may point back up the chain: an error that is
|
|
104
|
+
* its own cause would otherwise recurse until the stack ran out.
|
|
105
|
+
*/
|
|
106
|
+
const REDACT_DEPTH = 3;
|
|
107
|
+
function scrub(text, session, label = REDACTED) {
|
|
108
|
+
return text.includes(session) ? text.split(session).join(label) : text;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Scrub `message` and `stack` on `error` and on every `Error` hanging off it.
|
|
112
|
+
*
|
|
113
|
+
* `cause` and `errors` are followed because a logger prints them: Node's
|
|
114
|
+
* `util.inspect`, Pino's error serializer and Sentry all walk the cause chain,
|
|
115
|
+
* so a clean top-level message over an unscrubbed `cause.message` is the same
|
|
116
|
+
* leak one level down. A string `cause` is rewritten in place for the same
|
|
117
|
+
* reason; anything else there (a response object, say) is not a string a
|
|
118
|
+
* logger prints verbatim and is left alone.
|
|
119
|
+
*/
|
|
120
|
+
function redactError(error, session, depth, label = REDACTED) {
|
|
121
|
+
if (error.message.includes(session)) error.message = scrub(error.message, session, label);
|
|
122
|
+
if (typeof error.stack === "string" && error.stack.includes(session)) error.stack = scrub(error.stack, session, label);
|
|
123
|
+
if (depth >= REDACT_DEPTH) return;
|
|
124
|
+
if (error.cause instanceof Error) redactError(error.cause, session, depth + 1, label);
|
|
125
|
+
else if (typeof error.cause === "string") error.cause = scrub(error.cause, session, label);
|
|
126
|
+
if (error instanceof AggregateError) {
|
|
127
|
+
for (const inner of error.errors) if (inner instanceof Error) redactError(inner, session, depth + 1, label);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Scrub `session` out of whatever was thrown, in place, and hand it back.
|
|
132
|
+
*
|
|
133
|
+
* In place rather than re-wrapped: callers up the stack switch on
|
|
134
|
+
* `instanceof MedalApiError` and read `.status` / `.code`, and a fresh `Error`
|
|
135
|
+
* would erase all three. `message` and `stack` are the two strings a logger
|
|
136
|
+
* prints — on the error and, via `redactError`, on its `cause` chain;
|
|
137
|
+
* `MedalApiError.details` is `readonly` and carries the engine's structured
|
|
138
|
+
* body, which never echoes a request header.
|
|
139
|
+
*
|
|
140
|
+
* A non-`Error` throw that contains the session (a bare string, say) is
|
|
141
|
+
* REPLACED — there is nothing else on it worth keeping, and nothing safe to
|
|
142
|
+
* pass on.
|
|
143
|
+
*/
|
|
144
|
+
function redactSession(error, session) {
|
|
145
|
+
return redactSecret(error, {
|
|
146
|
+
value: session,
|
|
147
|
+
label: REDACTED
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/** `redactSession` for any secret: the Vipps grant, the token it buys, the API key. */
|
|
151
|
+
function redactSecret(error, secret) {
|
|
152
|
+
if (secret.value === "") return error;
|
|
153
|
+
if (error instanceof Error) {
|
|
154
|
+
redactError(error, secret.value, 0, secret.label);
|
|
155
|
+
return error;
|
|
156
|
+
}
|
|
157
|
+
if (typeof error === "string" && error.includes(secret.value)) return new Error(scrub(error, secret.value, secret.label));
|
|
158
|
+
return error;
|
|
159
|
+
}
|
|
160
|
+
function isDeadSession(error) {
|
|
161
|
+
return error instanceof MedalApiError && error.status === 401 && (error.code === "PORTAL_SESSION_REQUIRED" || error.code === "PORTAL_SESSION_INVALID");
|
|
162
|
+
}
|
|
163
|
+
function isThrottled(error) {
|
|
164
|
+
return error instanceof MedalApiError && error.status === 429;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Run one session-bound SDK call with the two guarantees from the module
|
|
168
|
+
* comment, plus the one mapping every such call shares: a 401 that names the
|
|
169
|
+
* session becomes `PortalSessionExpiredError`. Everything else is rethrown
|
|
170
|
+
* redacted, for the caller to interpret or for the action's error handler to
|
|
171
|
+
* turn into «something went wrong».
|
|
172
|
+
*/
|
|
173
|
+
async function withSession(session, call) {
|
|
174
|
+
try {
|
|
175
|
+
return await call(portalClient());
|
|
176
|
+
} catch (error) {
|
|
177
|
+
const scrubbed = redactSession(error, session);
|
|
178
|
+
if (isDeadSession(scrubbed)) throw new PortalSessionExpiredError();
|
|
179
|
+
throw scrubbed;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Ask Medal to e-mail a one-time code.
|
|
184
|
+
*
|
|
185
|
+
* Resolves on «sent» and says nothing about whether the address is known —
|
|
186
|
+
* that is Medal's silence to keep, and the action above this keeps it too. A
|
|
187
|
+
* 429 is the one answer translated (`PortalThrottledError`); anything else is
|
|
188
|
+
* rethrown as-is, because a login page that cannot reach Medal should say so.
|
|
189
|
+
*/
|
|
190
|
+
async function startLogin(email, locale = "no") {
|
|
191
|
+
try {
|
|
192
|
+
unwrap(await portalClient().login.start({
|
|
193
|
+
email,
|
|
194
|
+
locale
|
|
195
|
+
}), "/api/v1/portal/login/start");
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (isThrottled(error)) throw new PortalThrottledError();
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Exchange the code for a session. `null` for a wrong or expired code — that
|
|
203
|
+
* is an answer for the form, not a failure — and the token plus Medal's own
|
|
204
|
+
* expiry on success. The contact summary that rides along is dropped here:
|
|
205
|
+
* the page fetches the profile with the session it has just been given, and
|
|
206
|
+
* a second copy of the parent's name has no reader.
|
|
207
|
+
*/
|
|
208
|
+
async function verifyLogin(email, code) {
|
|
209
|
+
try {
|
|
210
|
+
const session = unwrap(await portalClient().login.verify({
|
|
211
|
+
email,
|
|
212
|
+
code
|
|
213
|
+
}), "/api/v1/portal/login/verify");
|
|
214
|
+
return {
|
|
215
|
+
sessionToken: session.session_token,
|
|
216
|
+
expiresAt: session.expires_at
|
|
217
|
+
};
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (error instanceof MedalApiError && error.status === 401 && error.code === "PORTAL_CODE_INVALID") return null;
|
|
220
|
+
if (isThrottled(error)) throw new PortalThrottledError();
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Revoke the session upstream. A 401 of any flavour is swallowed: it means
|
|
226
|
+
* Medal no longer honours this token, which is exactly the state a logout is
|
|
227
|
+
* trying to reach. A 5xx is NOT — the token may still be live, and the caller
|
|
228
|
+
* decides whether to clear the cookie anyway (it should: a cookie that stays
|
|
229
|
+
* in the jar is a cookie that stays stealable).
|
|
230
|
+
*/
|
|
231
|
+
async function logout(session) {
|
|
232
|
+
try {
|
|
233
|
+
await withSession(session, (portal) => portal.logout(session));
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (error instanceof PortalSessionExpiredError) return;
|
|
236
|
+
if (error instanceof MedalApiError && error.status === 401) return;
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async function getMe(session) {
|
|
241
|
+
const profile = await withSession(session, async (portal) => unwrap(await portal.me(session), "/api/v1/portal/me"));
|
|
242
|
+
return toPortalProfileDto(profile);
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* The engine names the code `VALIDATION_ERROR` whether it answers 400 or 422
|
|
246
|
+
* (`medal-client.ts` records that the booking routes see the latter), so the
|
|
247
|
+
* match is on the code alone. The message has already been through
|
|
248
|
+
* `redactSession` by the time it is re-thrown here — it is bound for a form.
|
|
249
|
+
*/
|
|
250
|
+
async function updateMe(session, patch) {
|
|
251
|
+
try {
|
|
252
|
+
const profile = await withSession(session, async (portal) => unwrap(await portal.updateMe(session, patch), "/api/v1/portal/me"));
|
|
253
|
+
return toPortalProfileDto(profile);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if (error instanceof MedalApiError && error.code === "VALIDATION_ERROR") throw new PortalValidationError(error.message);
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
async function getMyBookings(session) {
|
|
260
|
+
const bookings = await withSession(session, async (portal) => unwrap(await portal.myBookings(session), "/api/v1/portal/bookings"));
|
|
261
|
+
return {
|
|
262
|
+
upcoming: bookings.upcoming.map(toPortalBookingDto),
|
|
263
|
+
past: bookings.past.map(toPortalBookingDto)
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* The parent's own data, as Medal exports it — snake_case and all, because
|
|
268
|
+
* this is THEIR file (the GDPR right of access) and a projection would be
|
|
269
|
+
* this site editorialising it. One thing is taken out: every `manage_token`
|
|
270
|
+
* is nulled. A download sits in a Downloads folder, gets attached to an
|
|
271
|
+
* e-mail, gets synced to a phone; a live credential for moving an appointment
|
|
272
|
+
* does not belong in any of those places, and the same bookings are managed
|
|
273
|
+
* from the page with the session instead.
|
|
274
|
+
*
|
|
275
|
+
* Copies rather than mutates: the SDK's object is nobody else's, today, but a
|
|
276
|
+
* function that quietly edits its input is a bug waiting for a second reader.
|
|
277
|
+
*/
|
|
278
|
+
async function exportMyData(session) {
|
|
279
|
+
const exported = await withSession(session, async (portal) => unwrap(await portal.exportMyData(session), "/api/v1/portal/export"));
|
|
280
|
+
return {
|
|
281
|
+
...exported,
|
|
282
|
+
bookings: exported.bookings.map((booking) => ({
|
|
283
|
+
...booking,
|
|
284
|
+
manage_token: null
|
|
285
|
+
}))
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
async function deleteMe(session) {
|
|
289
|
+
await withSession(session, (portal) => portal.deleteMe(session));
|
|
290
|
+
}
|
|
291
|
+
/** Long enough for Medal to round-trip Vipps; short enough that a parent
|
|
292
|
+
* whose click went nowhere is told so before they give up on the page. */
|
|
293
|
+
const VIPPS_TIMEOUT_MS = 15e3;
|
|
294
|
+
/** The SDK's portal namespace on a client with the `VIPPS_TIMEOUT_MS` deadline. */
|
|
295
|
+
function quickPortal() {
|
|
296
|
+
return medal.requireMedalWithTimeout(VIPPS_TIMEOUT_MS).portal;
|
|
297
|
+
}
|
|
298
|
+
/** `body[key]`, or `undefined` for a body that is not an object. */
|
|
299
|
+
function field(body, key) {
|
|
300
|
+
return body && typeof body === "object" ? body[key] : void 0;
|
|
301
|
+
}
|
|
302
|
+
/** The `code` of a standard error envelope; `UNKNOWN_ERROR` for anything else. */
|
|
303
|
+
function vippsErrorCode(body) {
|
|
304
|
+
const code = field(field(body, "error"), "code");
|
|
305
|
+
return typeof code === "string" && code !== "" ? code : "UNKNOWN_ERROR";
|
|
306
|
+
}
|
|
307
|
+
/** The engine's own 404s on the persons routes; any other 404 is a missing route. */
|
|
308
|
+
const DOMAIN_NOT_FOUND = /* @__PURE__ */ new Set(["Person not found", "Resource not found"]);
|
|
309
|
+
function isDomainNotFound(message) {
|
|
310
|
+
return typeof message === "string" && DOMAIN_NOT_FOUND.has(message);
|
|
311
|
+
}
|
|
312
|
+
/** A 2xx whose body is not the shape the contract promises is not a success. */
|
|
313
|
+
function malformed(path) {
|
|
314
|
+
return new MedalApiError(200, "NON_JSON_BODY", `Medal returned a malformed body from ${path}`);
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* `POST` one Vipps route with the API key. Resolves with the `data` member
|
|
318
|
+
* of the parsed 2xx envelope; throws `MedalApiError` for anything else, with
|
|
319
|
+
* a message that names the status and the code and NOT the response text —
|
|
320
|
+
* the only strings Medal echoes on these routes are ones this site sent it.
|
|
321
|
+
*/
|
|
322
|
+
async function postVipps(path, body) {
|
|
323
|
+
return callMedal(path, {
|
|
324
|
+
method: "POST",
|
|
325
|
+
body
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* One raw request to Medal with the API key — and, for the routes that act as
|
|
330
|
+
* the parent, their session in `x-portal-session`, the header the SDK's own
|
|
331
|
+
* portal methods use. `postVipps` is this with `POST` and no session.
|
|
332
|
+
*
|
|
333
|
+
* A 204 resolves `undefined`; any other 2xx has to carry the `data` member.
|
|
334
|
+
*/
|
|
335
|
+
async function callMedal(path, { method, body, session }) {
|
|
336
|
+
const { key, base } = medal.requireMedalConfig();
|
|
337
|
+
const response = await fetch(new URL(path, base).toString(), {
|
|
338
|
+
method,
|
|
339
|
+
headers: {
|
|
340
|
+
authorization: `Bearer ${key}`,
|
|
341
|
+
/* v8 ignore next -- defensive: no caller sends a request without a body */
|
|
342
|
+
...body === void 0 ? {} : { "content-type": "application/json" },
|
|
343
|
+
accept: "application/json",
|
|
344
|
+
...session === void 0 ? {} : { "x-portal-session": session }
|
|
345
|
+
},
|
|
346
|
+
/* v8 ignore next -- defensive: no caller sends a request without a body */
|
|
347
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) },
|
|
348
|
+
signal: AbortSignal.timeout(VIPPS_TIMEOUT_MS)
|
|
349
|
+
});
|
|
350
|
+
if (response.status === 204) return void 0;
|
|
351
|
+
const text = await response.text();
|
|
352
|
+
let parsed;
|
|
353
|
+
try {
|
|
354
|
+
parsed = text ? JSON.parse(text) : void 0;
|
|
355
|
+
} catch {
|
|
356
|
+
parsed = void 0;
|
|
357
|
+
}
|
|
358
|
+
if (!response.ok) {
|
|
359
|
+
const code = vippsErrorCode(parsed);
|
|
360
|
+
throw new MedalApiError(response.status, code, `Medal answered ${response.status} ${code} from ${path}`, { domainNotFound: isDomainNotFound(field(field(parsed, "error"), "message")) });
|
|
361
|
+
}
|
|
362
|
+
const data = field(parsed, "data");
|
|
363
|
+
if (data === void 0) throw malformed(path);
|
|
364
|
+
return data;
|
|
365
|
+
}
|
|
366
|
+
/** Run `call`, scrubbing every secret in `secrets` out of whatever it throws. */
|
|
367
|
+
async function withVippsSecrets(secrets, call) {
|
|
368
|
+
try {
|
|
369
|
+
return await call();
|
|
370
|
+
} catch (error) {
|
|
371
|
+
let scrubbed = error;
|
|
372
|
+
for (const secret of secrets()) scrubbed = redactSecret(scrubbed, secret);
|
|
373
|
+
throw scrubbed;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function apiKeySecret() {
|
|
377
|
+
try {
|
|
378
|
+
return [{
|
|
379
|
+
value: medal.requireMedalConfig().key,
|
|
380
|
+
label: "<key>"
|
|
381
|
+
}];
|
|
382
|
+
} catch {
|
|
383
|
+
return [];
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/** The start route's three decisions, by code (and 429 by status); anything else is rethrown. */
|
|
387
|
+
function mapStartError(error) {
|
|
388
|
+
if (error instanceof MedalApiError) {
|
|
389
|
+
if (error.code === "VIPPS_NOT_CONFIGURED") throw new PortalVippsUnavailableError();
|
|
390
|
+
if (error.code === "INVALID_RETURN_URL") throw new PortalValidationError(error.message);
|
|
391
|
+
if (error.status === 429) throw new PortalThrottledError();
|
|
392
|
+
}
|
|
393
|
+
throw error;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Ask Medal where to send the parent to log in with Vipps.
|
|
397
|
+
*
|
|
398
|
+
* `returnUrl` is the absolute address of this site's `/min-side/vipps` route,
|
|
399
|
+
* which is where Vipps — via Medal — sends the browser back with either a
|
|
400
|
+
* grant or a reason. Medal checks it against the workspace's site and answers
|
|
401
|
+
* 400 `INVALID_RETURN_URL` for one it does not recognise; that surfaces as
|
|
402
|
+
* `PortalValidationError` because it is a configuration mistake on one side
|
|
403
|
+
* or the other, not something the parent did. 503 `VIPPS_NOT_CONFIGURED`
|
|
404
|
+
* means the workspace has no Vipps credentials and becomes
|
|
405
|
+
* `PortalVippsUnavailableError`, which the login page turns into «use e-mail
|
|
406
|
+
* instead». The address Medal hands back is checked to be HTTPS before anyone
|
|
407
|
+
* is redirected to it.
|
|
408
|
+
*/
|
|
409
|
+
async function startVippsLogin({ returnUrl, browserBinding, locale = "no" }) {
|
|
410
|
+
const path = "/api/v1/portal/vipps/start";
|
|
411
|
+
const secrets = () => [...browserBinding ? [{
|
|
412
|
+
value: browserBinding,
|
|
413
|
+
label: "<binding>"
|
|
414
|
+
}] : [], ...apiKeySecret()];
|
|
415
|
+
return withVippsSecrets(secrets, async () => {
|
|
416
|
+
const authorizeUrl = field(await postVipps(path, {
|
|
417
|
+
return_url: returnUrl,
|
|
418
|
+
...browserBinding ? { browser_binding: browserBinding } : {},
|
|
419
|
+
locale
|
|
420
|
+
}).catch(mapStartError), "authorize_url");
|
|
421
|
+
if (typeof authorizeUrl !== "string" || !authorizeUrl.startsWith("https://")) throw malformed(path);
|
|
422
|
+
return { authorizeUrl };
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Swap the grant Vipps sent the parent back with for a session. `null` when
|
|
427
|
+
* Medal does not know it (404 `GRANT_NOT_FOUND` — used already, expired, or
|
|
428
|
+
* minted for another workspace; the three are not distinguished, and none is
|
|
429
|
+
* a failure) and the token plus expiry otherwise, in the same shape
|
|
430
|
+
* `verifyLogin` returns so the route that receives it writes the cookie the
|
|
431
|
+
* same way.
|
|
432
|
+
*
|
|
433
|
+
* The grant and, once known, the token are scrubbed from anything thrown:
|
|
434
|
+
* the grant is in the request body, the token in the response, and a `fetch`
|
|
435
|
+
* failure or a serialiser can quote either.
|
|
436
|
+
*/
|
|
437
|
+
async function exchangeVippsGrant(grant) {
|
|
438
|
+
const path = "/api/v1/portal/vipps/exchange";
|
|
439
|
+
let token = "";
|
|
440
|
+
const secrets = () => [
|
|
441
|
+
{
|
|
442
|
+
value: grant,
|
|
443
|
+
label: "<grant>"
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
value: token,
|
|
447
|
+
label: REDACTED
|
|
448
|
+
},
|
|
449
|
+
...apiKeySecret()
|
|
450
|
+
];
|
|
451
|
+
return withVippsSecrets(secrets, async () => {
|
|
452
|
+
let data;
|
|
453
|
+
try {
|
|
454
|
+
data = unwrap(await quickPortal().login.vipps.exchange({ grant }), path);
|
|
455
|
+
} catch (error) {
|
|
456
|
+
if (error instanceof MedalApiError && error.code === "GRANT_NOT_FOUND") return null;
|
|
457
|
+
if (isThrottled(error)) throw new PortalThrottledError();
|
|
458
|
+
throw error;
|
|
459
|
+
}
|
|
460
|
+
const sessionToken = field(data, "session_token");
|
|
461
|
+
const expiresAt = field(data, "expires_at");
|
|
462
|
+
if (typeof sessionToken === "string") token = sessionToken;
|
|
463
|
+
if (token === "" || typeof expiresAt !== "number") throw malformed(path);
|
|
464
|
+
return {
|
|
465
|
+
sessionToken: token,
|
|
466
|
+
expiresAt
|
|
467
|
+
};
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
const PERSONS_PATH = "/api/v1/portal/me/persons";
|
|
471
|
+
/**
|
|
472
|
+
* A 404 that is not the engine's own «Person not found» / «Resource not
|
|
473
|
+
* found». The SDK keeps the engine's sentence as `message`; the raw
|
|
474
|
+
* `callMedal` drops it and records the verdict in `details.domainNotFound`.
|
|
475
|
+
*/
|
|
476
|
+
function isMissingRoute(error) {
|
|
477
|
+
if (!(error instanceof MedalApiError) || error.status !== 404) return false;
|
|
478
|
+
const domain = error.details?.domainNotFound === true || isDomainNotFound(error.message);
|
|
479
|
+
return !(error.code === "NOT_FOUND" && domain);
|
|
480
|
+
}
|
|
481
|
+
/** The editor's copy for what the engine refused. Never the engine's own
|
|
482
|
+
* sentence: it is written for integrators and names wire fields. */
|
|
483
|
+
function mapPersonError(error) {
|
|
484
|
+
if (error instanceof MedalApiError) {
|
|
485
|
+
if (error.code === "CONFLICT") throw new PortalValidationError(messages.duplicateChild);
|
|
486
|
+
if (error.code === "VALIDATION_ERROR" || error.code === "INVALID_INPUT") throw new PortalValidationError(messages.invalidChild);
|
|
487
|
+
if (error.status === 404) throw new PortalValidationError(messages.childNotFound);
|
|
488
|
+
if (error.status === 429) throw new PortalThrottledError();
|
|
489
|
+
}
|
|
490
|
+
throw error;
|
|
491
|
+
}
|
|
492
|
+
function personPatch(input) {
|
|
493
|
+
const body = {};
|
|
494
|
+
if (input.name !== void 0) body.name = input.name;
|
|
495
|
+
if (input.birthYear !== void 0) body.birth_year = input.birthYear;
|
|
496
|
+
if (input.birthMonth !== void 0) body.birth_month = input.birthMonth;
|
|
497
|
+
if (input.notes !== void 0) body.notes = input.notes;
|
|
498
|
+
if (input.preferredResourceId !== void 0) body.preferred_resource_id = input.preferredResourceId;
|
|
499
|
+
return body;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* On create an absent key and `null` mean the same — nothing — and the
|
|
503
|
+
* create schema is STRICT about nulls it does not take.
|
|
504
|
+
*/
|
|
505
|
+
function personCreate(input) {
|
|
506
|
+
const body = {
|
|
507
|
+
name: input.name,
|
|
508
|
+
birth_year: input.birthYear
|
|
509
|
+
};
|
|
510
|
+
if (input.birthMonth != null) body.birth_month = input.birthMonth;
|
|
511
|
+
if (input.notes != null) body.notes = input.notes;
|
|
512
|
+
if (input.preferredResourceId != null) body.preferred_resource_id = input.preferredResourceId;
|
|
513
|
+
return body;
|
|
514
|
+
}
|
|
515
|
+
/** Run one persons call with the session scrubbed and a dead one mapped. */
|
|
516
|
+
async function withPersonsCall(session, call) {
|
|
517
|
+
return withVippsSecrets(() => [{
|
|
518
|
+
value: session,
|
|
519
|
+
label: REDACTED
|
|
520
|
+
}, ...apiKeySecret()], async () => {
|
|
521
|
+
try {
|
|
522
|
+
return await call();
|
|
523
|
+
} catch (error) {
|
|
524
|
+
if (isDeadSession(error)) throw new PortalSessionExpiredError();
|
|
525
|
+
throw error;
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
function wireFamily(profile) {
|
|
530
|
+
return profile.family.map((member) => ({
|
|
531
|
+
name: member.name,
|
|
532
|
+
birth_year: member.birthYear
|
|
533
|
+
}));
|
|
534
|
+
}
|
|
535
|
+
/** The id the refreshed profile gives the child with this name and year. */
|
|
536
|
+
function idOf(profile, name, birthYear) {
|
|
537
|
+
const matches = profile.family.filter((member) => member.name === name && member.birthYear === birthYear);
|
|
538
|
+
return matches.length === 1 ? matches[0].personId : null;
|
|
539
|
+
}
|
|
540
|
+
/** Where a child sits in the list the fallback rewrites: by id, else by index. */
|
|
541
|
+
function familyIndex(profile, target) {
|
|
542
|
+
if (target.personId) return profile.family.findIndex((member) => member.personId === target.personId);
|
|
543
|
+
const index = target.index ?? -1;
|
|
544
|
+
return index >= 0 && index < profile.family.length ? index : -1;
|
|
545
|
+
}
|
|
546
|
+
async function createPerson(session, input) {
|
|
547
|
+
const created = await withPersonsCall(session, async () => {
|
|
548
|
+
try {
|
|
549
|
+
const personId = field(unwrap(await quickPortal().persons.create(session, personCreate(input)), PERSONS_PATH), "person_id");
|
|
550
|
+
return { personId: typeof personId === "string" ? personId : null };
|
|
551
|
+
} catch (error) {
|
|
552
|
+
if (isMissingRoute(error)) return null;
|
|
553
|
+
return mapPersonError(error);
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
if (created !== null) return {
|
|
557
|
+
profile: await getMe(session),
|
|
558
|
+
personId: created.personId,
|
|
559
|
+
fallback: false
|
|
560
|
+
};
|
|
561
|
+
const profile = await updateMe(session, { family: [...wireFamily(await getMe(session)), {
|
|
562
|
+
name: input.name,
|
|
563
|
+
birth_year: input.birthYear
|
|
564
|
+
}] });
|
|
565
|
+
return {
|
|
566
|
+
profile,
|
|
567
|
+
personId: idOf(profile, input.name, input.birthYear),
|
|
568
|
+
fallback: true
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
async function updatePerson(session, target, patch) {
|
|
572
|
+
const personId = target.personId ?? null;
|
|
573
|
+
if (personId !== null) {
|
|
574
|
+
if (await withPersonsCall(session, async () => {
|
|
575
|
+
try {
|
|
576
|
+
await callMedal(`${PERSONS_PATH}/${encodeURIComponent(personId)}`, {
|
|
577
|
+
method: "PATCH",
|
|
578
|
+
body: personPatch(patch),
|
|
579
|
+
session
|
|
580
|
+
});
|
|
581
|
+
return true;
|
|
582
|
+
} catch (error) {
|
|
583
|
+
if (isMissingRoute(error)) return false;
|
|
584
|
+
return mapPersonError(error);
|
|
585
|
+
}
|
|
586
|
+
})) return {
|
|
587
|
+
profile: await getMe(session),
|
|
588
|
+
personId,
|
|
589
|
+
fallback: false
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
const current = await getMe(session);
|
|
593
|
+
const index = familyIndex(current, target);
|
|
594
|
+
if (index === -1) mapPersonError(new MedalApiError(404, "NOT_FOUND", "person not in family"));
|
|
595
|
+
const family = wireFamily(current);
|
|
596
|
+
const name = patch.name ?? family[index].name;
|
|
597
|
+
const birthYear = patch.birthYear ?? family[index].birth_year;
|
|
598
|
+
family[index] = {
|
|
599
|
+
name,
|
|
600
|
+
birth_year: birthYear
|
|
601
|
+
};
|
|
602
|
+
const profile = await updateMe(session, { family });
|
|
603
|
+
return {
|
|
604
|
+
profile,
|
|
605
|
+
personId: idOf(profile, name, birthYear),
|
|
606
|
+
fallback: true
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
async function removePerson(session, target) {
|
|
610
|
+
const personId = target.personId ?? null;
|
|
611
|
+
if (personId !== null) {
|
|
612
|
+
if (await withPersonsCall(session, async () => {
|
|
613
|
+
try {
|
|
614
|
+
await quickPortal().persons.remove(session, personId);
|
|
615
|
+
return true;
|
|
616
|
+
} catch (error) {
|
|
617
|
+
if (isMissingRoute(error)) return false;
|
|
618
|
+
return mapPersonError(error);
|
|
619
|
+
}
|
|
620
|
+
})) return {
|
|
621
|
+
profile: await getMe(session),
|
|
622
|
+
personId,
|
|
623
|
+
fallback: false
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
const current = await getMe(session);
|
|
627
|
+
const index = familyIndex(current, target);
|
|
628
|
+
if (index === -1) mapPersonError(new MedalApiError(404, "NOT_FOUND", "person not in family"));
|
|
629
|
+
return {
|
|
630
|
+
profile: await updateMe(session, { family: wireFamily(current).filter((_, position) => position !== index) }),
|
|
631
|
+
personId,
|
|
632
|
+
fallback: true
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Confirm a conflicted Vipps login with the six-digit code Medal e-mailed to
|
|
637
|
+
* the address on file (SP10 A5): `POST /api/v1/portal/vipps/link/verify`.
|
|
638
|
+
*
|
|
639
|
+
* `null` for every refusal Medal folds into 401 `PORTAL_CODE_INVALID` — a
|
|
640
|
+
* wrong or burned code, an unknown, expired or spent link, a browser binding
|
|
641
|
+
* that is not the one the login started with — which is one answer for the
|
|
642
|
+
* form. 409 is `PortalVippsConflictError`, 429 `PortalThrottledError`.
|
|
643
|
+
*
|
|
644
|
+
* The link, the code, the binding and the session it buys are all scrubbed
|
|
645
|
+
* from anything thrown, with the API key. Through the SDK's
|
|
646
|
+
* `portal.login.vipps.verifyLink`, which sends it exactly once: the right code
|
|
647
|
+
* consumes the link, so a retry would misreport a completed login.
|
|
648
|
+
*/
|
|
649
|
+
async function verifyVippsLink({ link, code, browserBinding }) {
|
|
650
|
+
const path = "/api/v1/portal/vipps/link/verify";
|
|
651
|
+
let token = "";
|
|
652
|
+
const secrets = () => [
|
|
653
|
+
{
|
|
654
|
+
value: link,
|
|
655
|
+
label: "<link>"
|
|
656
|
+
},
|
|
657
|
+
{
|
|
658
|
+
value: code,
|
|
659
|
+
label: "<code>"
|
|
660
|
+
},
|
|
661
|
+
{
|
|
662
|
+
value: browserBinding,
|
|
663
|
+
label: "<binding>"
|
|
664
|
+
},
|
|
665
|
+
{
|
|
666
|
+
value: token,
|
|
667
|
+
label: REDACTED
|
|
668
|
+
},
|
|
669
|
+
...apiKeySecret()
|
|
670
|
+
];
|
|
671
|
+
return withVippsSecrets(secrets, async () => {
|
|
672
|
+
let data;
|
|
673
|
+
try {
|
|
674
|
+
data = unwrap(await quickPortal().login.vipps.verifyLink({
|
|
675
|
+
link,
|
|
676
|
+
code,
|
|
677
|
+
browser_binding: browserBinding
|
|
678
|
+
}), path);
|
|
679
|
+
} catch (error) {
|
|
680
|
+
return mapLinkError(error);
|
|
681
|
+
}
|
|
682
|
+
const sessionToken = field(data, "session_token");
|
|
683
|
+
const expiresAt = field(data, "expires_at");
|
|
684
|
+
if (typeof sessionToken === "string") token = sessionToken;
|
|
685
|
+
if (token === "" || typeof expiresAt !== "number") throw malformed(path);
|
|
686
|
+
return {
|
|
687
|
+
sessionToken: token,
|
|
688
|
+
expiresAt
|
|
689
|
+
};
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
/** The link verify's refusals: `null` for «wrong or expired», the two errors, else rethrown. */
|
|
693
|
+
function mapLinkError(error) {
|
|
694
|
+
if (error instanceof MedalApiError) {
|
|
695
|
+
if (error.status === 401 && error.code === "PORTAL_CODE_INVALID") return null;
|
|
696
|
+
if (error.status === 409 && error.code === "VIPPS_IDENTITY_CONFLICT") throw new PortalVippsConflictError();
|
|
697
|
+
if (error.status === 429) throw new PortalThrottledError();
|
|
698
|
+
}
|
|
699
|
+
throw error;
|
|
700
|
+
}
|
|
701
|
+
const VIPPS_LINK_PATH = "/api/v1/portal/me/vipps/link";
|
|
702
|
+
/** The session, the binding, the grant when there is one, and the key — scrubbed; a dead session mapped. */
|
|
703
|
+
async function withVippsLinkCall(session, extra, call) {
|
|
704
|
+
return withVippsSecrets(() => [
|
|
705
|
+
{
|
|
706
|
+
value: session,
|
|
707
|
+
label: REDACTED
|
|
708
|
+
},
|
|
709
|
+
...extra,
|
|
710
|
+
...apiKeySecret()
|
|
711
|
+
], async () => {
|
|
712
|
+
try {
|
|
713
|
+
return await call();
|
|
714
|
+
} catch (error) {
|
|
715
|
+
if (isDeadSession(error)) throw new PortalSessionExpiredError();
|
|
716
|
+
throw error;
|
|
717
|
+
}
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
function isVippsNotConfigured(error) {
|
|
721
|
+
return error instanceof MedalApiError && error.code === "VIPPS_NOT_CONFIGURED";
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Ask Medal where to send a logged-in parent to link Vipps. `null` against a
|
|
725
|
+
* Medal without the route AND for a workspace without Vipps (503
|
|
726
|
+
* `VIPPS_NOT_CONFIGURED`) — both hide the row, with nothing to say.
|
|
727
|
+
* `PortalThrottledError` for a 429, `PortalSessionExpiredError` for a dead
|
|
728
|
+
* session; the address is checked to be HTTPS.
|
|
729
|
+
*/
|
|
730
|
+
async function startVippsLink(session, { returnUrl, browserBinding, locale = "no" }) {
|
|
731
|
+
const path = `${VIPPS_LINK_PATH}/start`;
|
|
732
|
+
return withVippsLinkCall(session, [{
|
|
733
|
+
value: browserBinding,
|
|
734
|
+
label: "<binding>"
|
|
735
|
+
}], async () => {
|
|
736
|
+
let data;
|
|
737
|
+
try {
|
|
738
|
+
data = await callMedal(path, {
|
|
739
|
+
method: "POST",
|
|
740
|
+
body: {
|
|
741
|
+
return_url: returnUrl,
|
|
742
|
+
browser_binding: browserBinding,
|
|
743
|
+
locale
|
|
744
|
+
},
|
|
745
|
+
session
|
|
746
|
+
});
|
|
747
|
+
} catch (error) {
|
|
748
|
+
if (isMissingRoute(error) || isVippsNotConfigured(error)) return null;
|
|
749
|
+
if (isDeadSession(error)) throw error;
|
|
750
|
+
return mapStartError(error);
|
|
751
|
+
}
|
|
752
|
+
const authorizeUrl = field(data, "authorize_url");
|
|
753
|
+
if (typeof authorizeUrl !== "string" || !authorizeUrl.startsWith("https://")) throw malformed(path);
|
|
754
|
+
return { authorizeUrl };
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Spend the link grant Medal's callback handed back (medal-monorepo #5592).
|
|
759
|
+
*
|
|
760
|
+
* 200 `{ data: { status: 'linked', already_linked } }` is `linked` — a 2xx
|
|
761
|
+
* without that status is malformed, not a success. 409
|
|
762
|
+
* `VIPPS_IDENTITY_CONFLICT` is `conflict` (this Vipps account belongs to
|
|
763
|
+
* another profile). 404 `LINK_GRANT_NOT_FOUND` — an unknown, spent, expired
|
|
764
|
+
* or malformed grant, and (since the #5592 security review, which BURNS the
|
|
765
|
+
* grant on it) a wrong binding or another customer's session — is `invalid`,
|
|
766
|
+
* as are a 400 and a non-session 401. Every one of the three is final for
|
|
767
|
+
* the grant. A dead session and a throttle are the seam's usual errors.
|
|
768
|
+
*/
|
|
769
|
+
async function completeVippsLink(session, { grant, browserBinding }) {
|
|
770
|
+
const path = `${VIPPS_LINK_PATH}/complete`;
|
|
771
|
+
return withVippsLinkCall(session, [{
|
|
772
|
+
value: grant,
|
|
773
|
+
label: "<grant>"
|
|
774
|
+
}, {
|
|
775
|
+
value: browserBinding,
|
|
776
|
+
label: "<binding>"
|
|
777
|
+
}], async () => {
|
|
778
|
+
try {
|
|
779
|
+
if (field(await callMedal(path, {
|
|
780
|
+
method: "POST",
|
|
781
|
+
body: {
|
|
782
|
+
grant,
|
|
783
|
+
browser_binding: browserBinding
|
|
784
|
+
},
|
|
785
|
+
session
|
|
786
|
+
}), "status") !== "linked") throw malformed(path);
|
|
787
|
+
return "linked";
|
|
788
|
+
} catch (error) {
|
|
789
|
+
return mapCompleteError(error);
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
/** The complete route's refusals, as `VippsLinkCompletion`; anything else is rethrown. */
|
|
794
|
+
function mapCompleteError(error) {
|
|
795
|
+
if (!(error instanceof MedalApiError)) throw error;
|
|
796
|
+
const { status } = error;
|
|
797
|
+
if (isDeadSession(error)) throw error;
|
|
798
|
+
if (status === 409) return "conflict";
|
|
799
|
+
if (status === 400 || status === 401 || status === 404) return "invalid";
|
|
800
|
+
if (status === 429) throw new PortalThrottledError();
|
|
801
|
+
throw error;
|
|
802
|
+
}
|
|
803
|
+
return {
|
|
804
|
+
startLogin,
|
|
805
|
+
verifyLogin,
|
|
806
|
+
logout,
|
|
807
|
+
getMe,
|
|
808
|
+
updateMe,
|
|
809
|
+
getMyBookings,
|
|
810
|
+
exportMyData,
|
|
811
|
+
deleteMe,
|
|
812
|
+
startVippsLogin,
|
|
813
|
+
exchangeVippsGrant,
|
|
814
|
+
createPerson,
|
|
815
|
+
updatePerson,
|
|
816
|
+
removePerson,
|
|
817
|
+
verifyVippsLink,
|
|
818
|
+
startVippsLink,
|
|
819
|
+
completeVippsLink
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
//#endregion
|
|
823
|
+
export { PortalSessionExpiredError, PortalThrottledError, PortalValidationError, PortalVippsConflictError, PortalVippsUnavailableError, createPortalSeam };
|
|
824
|
+
|
|
825
|
+
//# sourceMappingURL=medal-portal.mjs.map
|