@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.
Files changed (266) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +6 -0
  4. package/README.md +220 -1
  5. package/dist/core/age.d.mts +34 -0
  6. package/dist/core/age.mjs +95 -0
  7. package/dist/core/age.mjs.map +1 -0
  8. package/dist/core/attempt-store.d.mts +111 -0
  9. package/dist/core/attempt-store.mjs +159 -0
  10. package/dist/core/attempt-store.mjs.map +1 -0
  11. package/dist/core/categories.d.mts +27 -0
  12. package/dist/core/categories.mjs +42 -0
  13. package/dist/core/categories.mjs.map +1 -0
  14. package/dist/core/clock.d.mts +33 -0
  15. package/dist/core/clock.mjs +251 -0
  16. package/dist/core/clock.mjs.map +1 -0
  17. package/dist/core/config.d.mts +178 -0
  18. package/dist/core/config.mjs +394 -0
  19. package/dist/core/config.mjs.map +1 -0
  20. package/dist/core/consent.d.mts +14 -0
  21. package/dist/core/consent.mjs +17 -0
  22. package/dist/core/consent.mjs.map +1 -0
  23. package/dist/core/deep-link.d.mts +40 -0
  24. package/dist/core/deep-link.mjs +160 -0
  25. package/dist/core/deep-link.mjs.map +1 -0
  26. package/dist/core/display-name.d.mts +18 -0
  27. package/dist/core/display-name.mjs +30 -0
  28. package/dist/core/display-name.mjs.map +1 -0
  29. package/dist/core/draft-store.d.mts +89 -0
  30. package/dist/core/draft-store.mjs +131 -0
  31. package/dist/core/draft-store.mjs.map +1 -0
  32. package/dist/core/dto.d.mts +46 -0
  33. package/dist/core/dto.mjs +157 -0
  34. package/dist/core/dto.mjs.map +1 -0
  35. package/dist/core/ics.d.mts +63 -0
  36. package/dist/core/ics.mjs +130 -0
  37. package/dist/core/ics.mjs.map +1 -0
  38. package/dist/core/index.d.mts +35 -0
  39. package/dist/core/index.mjs +32 -0
  40. package/dist/core/labels.d.mts +88 -0
  41. package/dist/core/labels.mjs +93 -0
  42. package/dist/core/labels.mjs.map +1 -0
  43. package/dist/core/machine.d.mts +645 -0
  44. package/dist/core/machine.mjs +988 -0
  45. package/dist/core/machine.mjs.map +1 -0
  46. package/dist/core/money.d.mts +12 -0
  47. package/dist/core/money.mjs +51 -0
  48. package/dist/core/money.mjs.map +1 -0
  49. package/dist/core/next-available.d.mts +20 -0
  50. package/dist/core/next-available.mjs +36 -0
  51. package/dist/core/next-available.mjs.map +1 -0
  52. package/dist/core/next-free.d.mts +25 -0
  53. package/dist/core/next-free.mjs +44 -0
  54. package/dist/core/next-free.mjs.map +1 -0
  55. package/dist/core/party-slots.d.mts +79 -0
  56. package/dist/core/party-slots.mjs +229 -0
  57. package/dist/core/party-slots.mjs.map +1 -0
  58. package/dist/core/paths.d.mts +11 -0
  59. package/dist/core/paths.mjs +56 -0
  60. package/dist/core/paths.mjs.map +1 -0
  61. package/dist/core/phone.d.mts +17 -0
  62. package/dist/core/phone.mjs +58 -0
  63. package/dist/core/phone.mjs.map +1 -0
  64. package/dist/core/portal/cookies.d.mts +29 -0
  65. package/dist/core/portal/dto.d.mts +140 -0
  66. package/dist/core/portal/dto.mjs +142 -0
  67. package/dist/core/portal/dto.mjs.map +1 -0
  68. package/dist/core/portal/family.d.mts +39 -0
  69. package/dist/core/portal/family.mjs +39 -0
  70. package/dist/core/portal/family.mjs.map +1 -0
  71. package/dist/core/portal/login-input.d.mts +29 -0
  72. package/dist/core/portal/login-input.mjs +29 -0
  73. package/dist/core/portal/login-input.mjs.map +1 -0
  74. package/dist/core/portal/next-path.d.mts +12 -0
  75. package/dist/core/portal/next-path.mjs +98 -0
  76. package/dist/core/portal/next-path.mjs.map +1 -0
  77. package/dist/core/portal/return-path.d.mts +7 -0
  78. package/dist/core/portal/return-path.mjs +49 -0
  79. package/dist/core/portal/return-path.mjs.map +1 -0
  80. package/dist/core/portal/session-cookie.d.mts +27 -0
  81. package/dist/core/portal/session-cookie.mjs +30 -0
  82. package/dist/core/portal/session-cookie.mjs.map +1 -0
  83. package/dist/core/portal/vipps-link.d.mts +56 -0
  84. package/dist/core/portal/vipps-link.mjs +136 -0
  85. package/dist/core/portal/vipps-link.mjs.map +1 -0
  86. package/dist/core/portal/vipps-return.d.mts +39 -0
  87. package/dist/core/portal/vipps-return.mjs +69 -0
  88. package/dist/core/portal/vipps-return.mjs.map +1 -0
  89. package/dist/core/rebook-store.d.mts +28 -0
  90. package/dist/core/rebook-store.mjs +60 -0
  91. package/dist/core/rebook-store.mjs.map +1 -0
  92. package/dist/core/restore-gate.d.mts +40 -0
  93. package/dist/core/restore-gate.mjs +96 -0
  94. package/dist/core/restore-gate.mjs.map +1 -0
  95. package/dist/core/stores.d.mts +8 -0
  96. package/dist/core/stores.mjs +23 -0
  97. package/dist/core/stores.mjs.map +1 -0
  98. package/dist/core/types.d.mts +245 -0
  99. package/dist/core/visit.d.mts +26 -0
  100. package/dist/core/visit.mjs +40 -0
  101. package/dist/core/visit.mjs.map +1 -0
  102. package/dist/core/wire.d.mts +37 -0
  103. package/dist/next/cache/memory.d.mts +14 -0
  104. package/dist/next/cache/memory.mjs +51 -0
  105. package/dist/next/cache/memory.mjs.map +1 -0
  106. package/dist/next/cache/next-data.d.mts +6 -0
  107. package/dist/next/cache/next-data.mjs +35 -0
  108. package/dist/next/cache/next-data.mjs.map +1 -0
  109. package/dist/next/cache/noop.d.mts +6 -0
  110. package/dist/next/cache/noop.mjs +15 -0
  111. package/dist/next/cache/noop.mjs.map +1 -0
  112. package/dist/next/cache/workers.d.mts +29 -0
  113. package/dist/next/cache/workers.mjs +74 -0
  114. package/dist/next/cache/workers.mjs.map +1 -0
  115. package/dist/next/catalogue.d.mts +38 -0
  116. package/dist/next/catalogue.mjs +215 -0
  117. package/dist/next/catalogue.mjs.map +1 -0
  118. package/dist/next/handler.d.mts +11 -0
  119. package/dist/next/handler.mjs +150 -0
  120. package/dist/next/handler.mjs.map +1 -0
  121. package/dist/next/index.d.mts +17 -0
  122. package/dist/next/index.mjs +14 -0
  123. package/dist/next/loaders.d.mts +79 -0
  124. package/dist/next/loaders.mjs +195 -0
  125. package/dist/next/loaders.mjs.map +1 -0
  126. package/dist/next/manage-dto.d.mts +6 -0
  127. package/dist/next/manage-dto.mjs +28 -0
  128. package/dist/next/manage-dto.mjs.map +1 -0
  129. package/dist/next/medal.d.mts +105 -0
  130. package/dist/next/medal.mjs +155 -0
  131. package/dist/next/medal.mjs.map +1 -0
  132. package/dist/next/options.d.mts +158 -0
  133. package/dist/next/options.mjs +65 -0
  134. package/dist/next/options.mjs.map +1 -0
  135. package/dist/next/portal/guardian.d.mts +42 -0
  136. package/dist/next/portal/guardian.mjs +76 -0
  137. package/dist/next/portal/guardian.mjs.map +1 -0
  138. package/dist/next/portal/medal-portal.d.mts +161 -0
  139. package/dist/next/portal/medal-portal.mjs +825 -0
  140. package/dist/next/portal/medal-portal.mjs.map +1 -0
  141. package/dist/next/portal/session.d.mts +38 -0
  142. package/dist/next/portal/session.mjs +40 -0
  143. package/dist/next/portal/session.mjs.map +1 -0
  144. package/dist/next/portal/vipps-flash.d.mts +14 -0
  145. package/dist/next/portal/vipps-flash.mjs +41 -0
  146. package/dist/next/portal/vipps-flash.mjs.map +1 -0
  147. package/dist/next/portal.d.mts +205 -0
  148. package/dist/next/portal.mjs +469 -0
  149. package/dist/next/portal.mjs.map +1 -0
  150. package/dist/next/request.d.mts +51 -0
  151. package/dist/next/request.mjs +123 -0
  152. package/dist/next/request.mjs.map +1 -0
  153. package/dist/next/routes/catalogue-routes.mjs +344 -0
  154. package/dist/next/routes/catalogue-routes.mjs.map +1 -0
  155. package/dist/next/routes/create.mjs +460 -0
  156. package/dist/next/routes/create.mjs.map +1 -0
  157. package/dist/next/routes/manage.mjs +191 -0
  158. package/dist/next/routes/manage.mjs.map +1 -0
  159. package/dist/next/routes/portal-routes.mjs +312 -0
  160. package/dist/next/routes/portal-routes.mjs.map +1 -0
  161. package/dist/next/routes/shared.mjs +162 -0
  162. package/dist/next/routes/shared.mjs.map +1 -0
  163. package/dist/next/routes/vipps-return.mjs +166 -0
  164. package/dist/next/routes/vipps-return.mjs.map +1 -0
  165. package/dist/next/runtime.d.mts +49 -0
  166. package/dist/next/runtime.mjs +94 -0
  167. package/dist/next/runtime.mjs.map +1 -0
  168. package/dist/next/seed.d.mts +71 -0
  169. package/dist/next/seed.mjs +495 -0
  170. package/dist/next/seed.mjs.map +1 -0
  171. package/dist/next/server.d.mts +26 -0
  172. package/dist/next/server.mjs +63 -0
  173. package/dist/next/server.mjs.map +1 -0
  174. package/dist/react/BookingLink.d.mts +61 -0
  175. package/dist/react/BookingLink.mjs +102 -0
  176. package/dist/react/BookingLink.mjs.map +1 -0
  177. package/dist/react/BookingWizard.d.mts +49 -0
  178. package/dist/react/BookingWizard.mjs +598 -0
  179. package/dist/react/BookingWizard.mjs.map +1 -0
  180. package/dist/react/LoginPageQuery.d.mts +37 -0
  181. package/dist/react/LoginPageQuery.mjs +106 -0
  182. package/dist/react/LoginPageQuery.mjs.map +1 -0
  183. package/dist/react/LoginSheet.d.mts +49 -0
  184. package/dist/react/LoginSheet.mjs +98 -0
  185. package/dist/react/LoginSheet.mjs.map +1 -0
  186. package/dist/react/ManageBooking.d.mts +50 -0
  187. package/dist/react/ManageBooking.mjs +197 -0
  188. package/dist/react/ManageBooking.mjs.map +1 -0
  189. package/dist/react/PortalDashboard.d.mts +10 -0
  190. package/dist/react/Provider.d.mts +82 -0
  191. package/dist/react/Provider.mjs +132 -0
  192. package/dist/react/Provider.mjs.map +1 -0
  193. package/dist/react/actions.d.mts +133 -0
  194. package/dist/react/actions.mjs +90 -0
  195. package/dist/react/actions.mjs.map +1 -0
  196. package/dist/react/index.d.mts +28 -0
  197. package/dist/react/index.mjs +20 -0
  198. package/dist/react/kit.d.mts +54 -0
  199. package/dist/react/kit.mjs +127 -0
  200. package/dist/react/kit.mjs.map +1 -0
  201. package/dist/react/labels/index.d.mts +23 -0
  202. package/dist/react/labels/index.mjs +44 -0
  203. package/dist/react/labels/index.mjs.map +1 -0
  204. package/dist/react/labels/login.d.mts +10 -0
  205. package/dist/react/labels/login.mjs +13 -0
  206. package/dist/react/labels/login.mjs.map +1 -0
  207. package/dist/react/labels/manage.d.mts +15 -0
  208. package/dist/react/labels/manage.mjs +19 -0
  209. package/dist/react/labels/manage.mjs.map +1 -0
  210. package/dist/react/labels/portal.d.mts +25 -0
  211. package/dist/react/labels/portal.mjs +41 -0
  212. package/dist/react/labels/portal.mjs.map +1 -0
  213. package/dist/react/labels/screens.d.mts +11 -0
  214. package/dist/react/labels/screens.mjs +721 -0
  215. package/dist/react/labels/screens.mjs.map +1 -0
  216. package/dist/react/labels/wizard.d.mts +76 -0
  217. package/dist/react/labels/wizard.mjs +97 -0
  218. package/dist/react/labels/wizard.mjs.map +1 -0
  219. package/dist/react/link/index.d.mts +5 -0
  220. package/dist/react/link/index.mjs +5 -0
  221. package/dist/react/portal/SessionTouch.d.mts +19 -0
  222. package/dist/react/portal/SessionTouch.mjs +40 -0
  223. package/dist/react/portal/SessionTouch.mjs.map +1 -0
  224. package/dist/react/portal/age-prompt.d.mts +10 -0
  225. package/dist/react/portal/age-prompt.mjs +21 -0
  226. package/dist/react/portal/age-prompt.mjs.map +1 -0
  227. package/dist/react/portal/book-again.d.mts +18 -0
  228. package/dist/react/portal/book-again.mjs +21 -0
  229. package/dist/react/portal/book-again.mjs.map +1 -0
  230. package/dist/react/portal/child-summary.d.mts +17 -0
  231. package/dist/react/portal/child-summary.mjs +45 -0
  232. package/dist/react/portal/child-summary.mjs.map +1 -0
  233. package/dist/react/portal/dashboard.d.mts +74 -0
  234. package/dist/react/portal/dashboard.mjs +204 -0
  235. package/dist/react/portal/dashboard.mjs.map +1 -0
  236. package/dist/react/portal/leave.d.mts +17 -0
  237. package/dist/react/portal/leave.mjs +21 -0
  238. package/dist/react/portal/leave.mjs.map +1 -0
  239. package/dist/react/portal/sections.d.mts +12 -0
  240. package/dist/react/portal/sections.mjs +48 -0
  241. package/dist/react/portal/sections.mjs.map +1 -0
  242. package/dist/react/portal/tabs.d.mts +23 -0
  243. package/dist/react/portal/tabs.mjs +36 -0
  244. package/dist/react/portal/tabs.mjs.map +1 -0
  245. package/dist/react/portal/vipps-flash.d.mts +11 -0
  246. package/dist/react/portal/vipps-flash.mjs +18 -0
  247. package/dist/react/portal/vipps-flash.mjs.map +1 -0
  248. package/dist/react/portal/wired.mjs +202 -0
  249. package/dist/react/portal/wired.mjs.map +1 -0
  250. package/dist/react/screen-labels.mjs +14 -0
  251. package/dist/react/screen-labels.mjs.map +1 -0
  252. package/dist/react/shared.d.mts +13 -0
  253. package/dist/react/shared.mjs +7 -0
  254. package/dist/react/use-next-free.d.mts +19 -0
  255. package/dist/react/use-next-free.mjs +56 -0
  256. package/dist/react/use-next-free.mjs.map +1 -0
  257. package/dist/react/useBooking.d.mts +179 -0
  258. package/dist/react/useBooking.mjs +1331 -0
  259. package/dist/react/useBooking.mjs.map +1 -0
  260. package/dist/react/wizard/adapters.mjs +189 -0
  261. package/dist/react/wizard/adapters.mjs.map +1 -0
  262. package/dist/react/wizard/index.d.mts +11 -0
  263. package/dist/react/wizard/index.mjs +6 -0
  264. package/dist/react/wizard-error.mjs +29 -0
  265. package/dist/react/wizard-error.mjs.map +1 -0
  266. package/package.json +126 -5
@@ -0,0 +1,469 @@
1
+ import { emailField, verifyLoginInput } from "../core/portal/login-input.mjs";
2
+ import { mintBrowserBinding } from "../core/portal/vipps-link.mjs";
3
+ import { PortalSessionExpiredError, PortalThrottledError, PortalValidationError, PortalVippsUnavailableError } from "./portal/medal-portal.mjs";
4
+ import { originFromHeaders } from "./request.mjs";
5
+ import { vippsLinkReturnRoute, vippsReturnRoute } from "./routes/vipps-return.mjs";
6
+ import { z } from "zod";
7
+ import { redirect } from "next/navigation";
8
+ import { headers } from "next/headers";
9
+ import { revalidatePath } from "next/cache";
10
+ //#region src/next/portal.ts
11
+ /**
12
+ * The customer portal's server half: the session cookie, the action functions
13
+ * behind the portal's forms, and the two Vipps return routes.
14
+ *
15
+ * SERVER ACTIONS STAY IN THE APP. A library cannot ship `'use server'` exports
16
+ * reliably, so these are plain async functions and the app wraps each in a
17
+ * three-line action:
18
+ *
19
+ * ```ts
20
+ * 'use server';
21
+ * export async function updateProfileAction(input: unknown) {
22
+ * return booking.portal.actions.updateProfile(input);
23
+ * }
24
+ * ```
25
+ *
26
+ * The components receive the actions as props, so nothing in the package
27
+ * imports from an app file.
28
+ *
29
+ * Every action keeps the same two obligations:
30
+ *
31
+ * - **The session never leaves the server.** It is read from the cookie
32
+ * inside `withPortalSession`, handed to the seam, and appears in no return
33
+ * value — an action's result is serialised into the browser.
34
+ * - **A dead session is an answer, not an error.** Medal saying the cookie is
35
+ * no longer honoured comes back as `{ ok: false, reason: 'session' }` with
36
+ * the cookie already cleared.
37
+ *
38
+ * Inputs are validated here first, in the site's own words (`schemas` are
39
+ * exported too, for an app that validates with its own action client). What
40
+ * the engine still rejects comes back as `{ ok: false, reason: 'invalid',
41
+ * message }`. Anything else is thrown, for the app's action client to turn
42
+ * into a generic error — which is why the seam scrubs the session out of
43
+ * every error first.
44
+ */
45
+ function isOk(result) {
46
+ return typeof result === "object" && result !== null && "ok" in result && result.ok === true;
47
+ }
48
+ /** The first issue's message, for a form that has no sentence of its own. */
49
+ function invalid(error) {
50
+ /* v8 ignore next -- defensive: a failed parse always carries at least one issue */
51
+ return {
52
+ ok: false,
53
+ reason: "invalid",
54
+ message: error.issues[0]?.message ?? "Invalid input"
55
+ };
56
+ }
57
+ /** The schemas the actions validate with, for an app's own action client. */
58
+ function createPortalSchemas(rt) {
59
+ const { messages, phone } = rt;
60
+ const nameField = z.string().trim().min(1).max(60);
61
+ /**
62
+ * The same rule the wizard applies on the details step, normalised the same
63
+ * way, so a parent who books with one spelling and edits with another stays
64
+ * one contact. `''` clears.
65
+ */
66
+ const phoneField = z.string().transform((value, ctx) => {
67
+ const trimmed = value.trim();
68
+ if (trimmed === "") return null;
69
+ if (!phone.looksValid(trimmed)) {
70
+ ctx.addIssue({
71
+ code: "custom",
72
+ message: messages.invalidPhone
73
+ });
74
+ return z.NEVER;
75
+ }
76
+ return phone.nationalDigits(trimmed);
77
+ });
78
+ /** Whole, plausible years. */
79
+ const birthYearField = z.number().int().refine((year) => year >= 1900 && year <= (/* @__PURE__ */ new Date()).getFullYear(), { message: messages.invalidBirthYear });
80
+ const personFields = z.object({
81
+ name: nameField,
82
+ birth_year: birthYearField,
83
+ birth_month: z.number().int().min(1).max(12).nullable().optional(),
84
+ notes: z.string().trim().max(500).nullable().optional(),
85
+ preferred_resource_id: z.string().regex(/^[\w-]{1,64}$/).nullable().optional()
86
+ });
87
+ /** Which child: the id wherever the profile named one, else the index. */
88
+ const personTarget = z.object({
89
+ person_id: z.string().min(1).max(64).nullable().optional(),
90
+ index: z.number().int().min(0).max(19).optional()
91
+ });
92
+ return {
93
+ startLogin: z.object({ email: emailField }),
94
+ verifyLogin: verifyLoginInput,
95
+ updateProfile: z.object({
96
+ first_name: nameField.optional(),
97
+ last_name: nameField.optional(),
98
+ phone: z.union([z.null(), phoneField]).optional(),
99
+ family: z.array(z.object({
100
+ name: nameField,
101
+ birth_year: birthYearField
102
+ })).max(10).optional()
103
+ }),
104
+ createPerson: z.object({ person: personFields }),
105
+ updatePerson: personTarget.extend({ person: personFields }),
106
+ savePerson: personTarget.extend({
107
+ person: personFields,
108
+ create: z.boolean()
109
+ }),
110
+ removePerson: personTarget,
111
+ setMarketingConsent: z.object({ granted: z.boolean() }),
112
+ deleteMe: z.object({ confirm: z.literal(rt.options.portal?.deleteConfirmWord ?? "SLETT") })
113
+ };
114
+ }
115
+ /** An empty string is «cleared» for the optional text fields (absent is the caller's). */
116
+ function blankIsNull(value) {
117
+ return value === null || value === "" ? null : value;
118
+ }
119
+ function createPortalActions(rt) {
120
+ const schemas = createPortalSchemas(rt);
121
+ const { portal, session: jar, vippsLink, nextPath } = rt;
122
+ const configuredLocale = rt.options.portal?.locale;
123
+ const locale = configuredLocale ?? "no";
124
+ const portalPath = rt.paths.portal ?? rt.config.paths.booking;
125
+ /**
126
+ * Run `fn` with the parent's session, or say why not. No cookie and a
127
+ * cookie Medal refuses are one answer, and both clear the cookie. When `fn`
128
+ * comes back `{ ok: true }`, Medal has just slid the session and the cookie
129
+ * is renewed to match — never for a delete (which clears it) or an export.
130
+ */
131
+ async function withPortalSession(fn, { renew = true } = {}) {
132
+ const session = await jar.readPortalSession();
133
+ if (session === null) {
134
+ await jar.clearPortalSession();
135
+ return {
136
+ ok: false,
137
+ reason: "session"
138
+ };
139
+ }
140
+ try {
141
+ const result = await fn(session);
142
+ if (renew && isOk(result)) await jar.renewPortalSession(session);
143
+ return result;
144
+ } catch (error) {
145
+ if (error instanceof PortalSessionExpiredError) {
146
+ await jar.clearPortalSession();
147
+ return {
148
+ ok: false,
149
+ reason: "session"
150
+ };
151
+ }
152
+ throw error;
153
+ }
154
+ }
155
+ /** The origin this request arrived on, for an absolute return URL. */
156
+ async function requestOrigin() {
157
+ return originFromHeaders(await headers(), rt.baseUrl() ?? "");
158
+ }
159
+ function personAnswer(result) {
160
+ revalidatePath(portalPath);
161
+ return {
162
+ ok: true,
163
+ profile: result.profile,
164
+ personId: result.personId,
165
+ fallback: result.fallback
166
+ };
167
+ }
168
+ async function asPersonAction(run) {
169
+ return withPortalSession(async (session) => {
170
+ try {
171
+ return personAnswer(await run(session));
172
+ } catch (error) {
173
+ if (error instanceof PortalValidationError) return {
174
+ ok: false,
175
+ reason: "invalid",
176
+ message: error.message
177
+ };
178
+ throw error;
179
+ }
180
+ });
181
+ }
182
+ function personInput(person) {
183
+ return {
184
+ name: person.name,
185
+ birthYear: person.birth_year,
186
+ ...person.birth_month === void 0 ? {} : { birthMonth: person.birth_month },
187
+ ...person.notes === void 0 ? {} : { notes: blankIsNull(person.notes) },
188
+ ...person.preferred_resource_id === void 0 ? {} : { preferredResourceId: blankIsNull(person.preferred_resource_id) }
189
+ };
190
+ }
191
+ return {
192
+ /**
193
+ * Ask for a code. Always «sent» — including when Medal is throttling this
194
+ * address: a different answer would let anyone confirm which parents are
195
+ * customers by asking a few times.
196
+ */
197
+ async startLogin(input) {
198
+ const parsed = schemas.startLogin.safeParse(input);
199
+ if (!parsed.success) return invalid(parsed.error);
200
+ try {
201
+ await (configuredLocale === void 0 ? portal.startLogin(parsed.data.email) : portal.startLogin(parsed.data.email, configuredLocale));
202
+ } catch (error) {
203
+ if (!(error instanceof PortalThrottledError)) throw error;
204
+ }
205
+ return { status: "sent" };
206
+ },
207
+ /**
208
+ * Exchange the code for the cookie. Prefer the `login/verify` ROUTE from
209
+ * the browser: an action that sets a cookie makes Next re-render the page
210
+ * it was called from. This is for a page that wants that.
211
+ */
212
+ async verifyLogin(input) {
213
+ const parsed = schemas.verifyLogin.safeParse(input);
214
+ if (!parsed.success) return {
215
+ ok: false,
216
+ reason: "invalid"
217
+ };
218
+ let issued;
219
+ try {
220
+ issued = await portal.verifyLogin(parsed.data.email, parsed.data.code);
221
+ } catch (error) {
222
+ if (error instanceof PortalThrottledError) return {
223
+ ok: false,
224
+ reason: "throttled"
225
+ };
226
+ rt.logger.error(error, "Portal login code could not be verified");
227
+ return {
228
+ ok: false,
229
+ reason: "unreachable"
230
+ };
231
+ }
232
+ if (issued === null) return {
233
+ ok: false,
234
+ reason: "invalid"
235
+ };
236
+ await jar.writePortalSession(issued.sessionToken, issued.expiresAt);
237
+ return {
238
+ ok: true,
239
+ guardian: await rt.guardianFromSession(issued.sessionToken)
240
+ };
241
+ },
242
+ /**
243
+ * Send the parent to Vipps — the `action` of a `<form>` driven by
244
+ * `useActionState`, so `(previous, formData)` and the answer IS the next
245
+ * state; the success path never returns (`redirect()` throws, outside
246
+ * the `try`).
247
+ *
248
+ * The return URL is the Vipps return route on the origin the request
249
+ * arrived on (the canonical `baseUrl` when it names none): a preview host
250
+ * must not send its parents back to production. `next` is where a login
251
+ * that did not start on the portal should land; it is validated and kept
252
+ * in a cookie, written on EVERY attempt (`null` included) so a leftover
253
+ * destination cannot capture an ordinary login.
254
+ */
255
+ async startVipps(_previous, formData) {
256
+ const next = formData?.get("next");
257
+ await nextPath.writePortalNextPath(typeof next === "string" ? next : null);
258
+ const returnUrl = new URL(rt.paths.vippsReturn, await requestOrigin()).toString();
259
+ const browserBinding = mintBrowserBinding();
260
+ await vippsLink.writeBrowserBinding(browserBinding);
261
+ let authorizeUrl;
262
+ try {
263
+ ({authorizeUrl} = await portal.startVippsLogin({
264
+ returnUrl,
265
+ browserBinding,
266
+ locale
267
+ }));
268
+ } catch (error) {
269
+ await vippsLink.clearBrowserBinding();
270
+ if (error instanceof PortalThrottledError) return {
271
+ ok: false,
272
+ reason: "throttled"
273
+ };
274
+ if (!(error instanceof PortalVippsUnavailableError)) rt.logger.error(error, "Vipps login could not be started");
275
+ return {
276
+ ok: false,
277
+ reason: "unavailable"
278
+ };
279
+ }
280
+ redirect(authorizeUrl);
281
+ },
282
+ /**
283
+ * «Link Vipps» on the profile: send a LOGGED-IN parent to Vipps to link it
284
+ * to the profile they are in. The same shape as `startVipps`, with the
285
+ * session as the difference. A Medal without the route is `missing`.
286
+ */
287
+ async startVippsLink(_previous, _formData) {
288
+ const session = await jar.readPortalSession();
289
+ if (session === null) return {
290
+ ok: false,
291
+ reason: "session"
292
+ };
293
+ const returnUrl = new URL(rt.paths.vippsLinkReturn, await requestOrigin()).toString();
294
+ const browserBinding = mintBrowserBinding();
295
+ await vippsLink.writeBrowserBinding(browserBinding);
296
+ let authorizeUrl;
297
+ try {
298
+ const started = await portal.startVippsLink(session, {
299
+ returnUrl,
300
+ browserBinding,
301
+ locale
302
+ });
303
+ if (started === null) {
304
+ await vippsLink.clearBrowserBinding();
305
+ return {
306
+ ok: false,
307
+ reason: "missing"
308
+ };
309
+ }
310
+ authorizeUrl = started.authorizeUrl;
311
+ } catch (error) {
312
+ await vippsLink.clearBrowserBinding();
313
+ if (error instanceof PortalSessionExpiredError) {
314
+ await jar.clearPortalSession();
315
+ return {
316
+ ok: false,
317
+ reason: "session"
318
+ };
319
+ }
320
+ if (error instanceof PortalThrottledError) return {
321
+ ok: false,
322
+ reason: "throttled"
323
+ };
324
+ if (!(error instanceof PortalVippsUnavailableError)) rt.logger.error(error, "Vipps link could not be started");
325
+ return {
326
+ ok: false,
327
+ reason: "unavailable"
328
+ };
329
+ }
330
+ redirect(authorizeUrl);
331
+ },
332
+ /**
333
+ * Log out. The cookie is cleared whatever Medal says, but only AFTER the
334
+ * revocation is attempted. A revocation that fails is swallowed, and not
335
+ * logged: the only thing worth saying about it would name the session.
336
+ */
337
+ async logout() {
338
+ const session = await jar.readPortalSession();
339
+ if (session !== null) try {
340
+ await portal.logout(session);
341
+ } catch {}
342
+ await jar.clearPortalSession();
343
+ return { ok: true };
344
+ },
345
+ /**
346
+ * Edit name, phone and children. The patch carries only the keys the
347
+ * parent sent. Every successful write revalidates the portal page, so Back
348
+ * does not show the values from before the save.
349
+ */
350
+ async updateProfile(input) {
351
+ const parsed = schemas.updateProfile.safeParse(input);
352
+ if (!parsed.success) return invalid(parsed.error);
353
+ return withPortalSession(async (session) => {
354
+ try {
355
+ const profile = await portal.updateMe(session, parsed.data);
356
+ revalidatePath(portalPath);
357
+ return {
358
+ ok: true,
359
+ profile
360
+ };
361
+ } catch (error) {
362
+ if (error instanceof PortalValidationError) return {
363
+ ok: false,
364
+ reason: "invalid",
365
+ message: error.message
366
+ };
367
+ throw error;
368
+ }
369
+ });
370
+ },
371
+ /** Add one child (the person endpoints, else the whole-list fallback). */
372
+ async createPerson(input) {
373
+ const parsed = schemas.createPerson.safeParse(input);
374
+ if (!parsed.success) return invalid(parsed.error);
375
+ const person = personInput(parsed.data.person);
376
+ return asPersonAction((session) => portal.createPerson(session, person));
377
+ },
378
+ /** Edit one child in place — the id survives a rename. */
379
+ async updatePerson(input) {
380
+ const parsed = schemas.updatePerson.safeParse(input);
381
+ if (!parsed.success) return invalid(parsed.error);
382
+ const person = personInput(parsed.data.person);
383
+ const target = {
384
+ personId: parsed.data.person_id,
385
+ index: parsed.data.index
386
+ };
387
+ return asPersonAction((session) => portal.updatePerson(session, target, person));
388
+ },
389
+ /** `createPerson` or `updatePerson`, by the `create` flag — one form, one action. */
390
+ async savePerson(input) {
391
+ const parsed = schemas.savePerson.safeParse(input);
392
+ if (!parsed.success) return invalid(parsed.error);
393
+ const person = personInput(parsed.data.person);
394
+ return asPersonAction((session) => parsed.data.create ? portal.createPerson(session, person) : portal.updatePerson(session, {
395
+ personId: parsed.data.person_id,
396
+ index: parsed.data.index
397
+ }, person));
398
+ },
399
+ /** Remove one child. Medal deactivates the person; its booking history stays. */
400
+ async removePerson(input) {
401
+ const parsed = schemas.removePerson.safeParse(input);
402
+ if (!parsed.success) return invalid(parsed.error);
403
+ const target = {
404
+ personId: parsed.data.person_id,
405
+ index: parsed.data.index
406
+ };
407
+ return asPersonAction((session) => portal.removePerson(session, target));
408
+ },
409
+ /**
410
+ * The marketing box on the profile, through the same profile patch, and
411
+ * echoing the STORED value so the checkbox shows what Medal has.
412
+ */
413
+ async setMarketingConsent(input) {
414
+ const parsed = schemas.setMarketingConsent.safeParse(input);
415
+ if (!parsed.success) return invalid(parsed.error);
416
+ return withPortalSession(async (session) => {
417
+ const profile = await portal.updateMe(session, { marketing_consent: parsed.data.granted });
418
+ revalidatePath(portalPath);
419
+ return {
420
+ ok: true,
421
+ marketingConsent: profile.marketingConsent
422
+ };
423
+ });
424
+ },
425
+ /**
426
+ * The parent's data as a file (GDPR art. 15), pretty-printed and dated.
427
+ * No renewal: a cookie set by an action re-renders the page, which would
428
+ * hold the download behind a full dashboard render.
429
+ */
430
+ async exportData() {
431
+ return withPortalSession(async (session) => {
432
+ const exported = await portal.exportMyData(session);
433
+ const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
434
+ return {
435
+ ok: true,
436
+ filename: `${rt.options.portal?.exportFilePrefix ?? "min-side-eksport"}-${date}.json`,
437
+ json: JSON.stringify(exported, null, 2)
438
+ };
439
+ }, { renew: false });
440
+ },
441
+ /**
442
+ * Delete the account (GDPR art. 17). The confirmation word is checked
443
+ * here rather than trusted from a disabled button. The cookie is cleared
444
+ * only after Medal has said yes.
445
+ */
446
+ async deleteMe(input) {
447
+ const parsed = schemas.deleteMe.safeParse(input);
448
+ if (!parsed.success) return invalid(parsed.error);
449
+ return withPortalSession(async (session) => {
450
+ await portal.deleteMe(session);
451
+ await jar.clearPortalSession();
452
+ return { ok: true };
453
+ }, { renew: false });
454
+ }
455
+ };
456
+ }
457
+ function createPortalFor(rt) {
458
+ return {
459
+ session: rt.session,
460
+ actions: createPortalActions(rt),
461
+ schemas: createPortalSchemas(rt),
462
+ vippsReturn: { GET: (request) => vippsReturnRoute(rt, request) },
463
+ vippsLinkReturn: { GET: (request) => vippsLinkReturnRoute(rt, request) }
464
+ };
465
+ }
466
+ //#endregion
467
+ export { createPortalActions, createPortalFor, createPortalSchemas };
468
+
469
+ //# sourceMappingURL=portal.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"portal.mjs","names":[],"sources":["../../src/next/portal.ts"],"sourcesContent":["/**\n * The customer portal's server half: the session cookie, the action functions\n * behind the portal's forms, and the two Vipps return routes.\n *\n * SERVER ACTIONS STAY IN THE APP. A library cannot ship `'use server'` exports\n * reliably, so these are plain async functions and the app wraps each in a\n * three-line action:\n *\n * ```ts\n * 'use server';\n * export async function updateProfileAction(input: unknown) {\n * return booking.portal.actions.updateProfile(input);\n * }\n * ```\n *\n * The components receive the actions as props, so nothing in the package\n * imports from an app file.\n *\n * Every action keeps the same two obligations:\n *\n * - **The session never leaves the server.** It is read from the cookie\n * inside `withPortalSession`, handed to the seam, and appears in no return\n * value — an action's result is serialised into the browser.\n * - **A dead session is an answer, not an error.** Medal saying the cookie is\n * no longer honoured comes back as `{ ok: false, reason: 'session' }` with\n * the cookie already cleared.\n *\n * Inputs are validated here first, in the site's own words (`schemas` are\n * exported too, for an app that validates with its own action client). What\n * the engine still rejects comes back as `{ ok: false, reason: 'invalid',\n * message }`. Anything else is thrown, for the app's action client to turn\n * into a generic error — which is why the seam scrubs the session out of\n * every error first.\n */\n\nimport { revalidatePath } from 'next/cache';\nimport { headers } from 'next/headers';\nimport { redirect } from 'next/navigation';\nimport { z } from 'zod';\nimport type { PortalProfileDto } from '../core/portal/dto';\nimport { emailField, verifyLoginInput } from '../core/portal/login-input';\nimport { mintBrowserBinding } from '../core/portal/vipps-link';\nimport type { BookingGuardian } from '../core/types';\nimport {\n type PortalPersonResult,\n PortalSessionExpiredError,\n PortalThrottledError,\n PortalValidationError,\n PortalVippsUnavailableError,\n} from './portal/medal-portal';\nimport { originFromHeaders } from './request';\nimport { vippsLinkReturnRoute, vippsReturnRoute } from './routes/vipps-return';\nimport type { BookingRuntime } from './runtime';\n\nexport type SessionFailure = { ok: false; reason: 'session' };\nexport type InvalidInput = { ok: false; reason: 'invalid'; message: string };\n\n/** What the Vipps button hears back when it did NOT get a redirect. */\nexport type VippsStartState = { ok: false; reason: 'unavailable' | 'throttled' } | null;\n\n/** What «link Vipps» hears back when it did NOT get a redirect. */\nexport type VippsLinkStartState = {\n ok: false;\n /** `missing`: this Medal has no link route — the row hides itself. */\n reason: 'missing' | 'session' | 'unavailable' | 'throttled';\n} | null;\n\nexport type UpdateProfileResult =\n | { ok: true; profile: PortalProfileDto }\n | SessionFailure\n | InvalidInput;\n\nexport type PersonActionResult =\n | { ok: true; profile: PortalProfileDto; personId: string | null; fallback: boolean }\n | SessionFailure\n | InvalidInput;\n\nexport type VerifyLoginResult =\n | { ok: true; guardian: BookingGuardian | null }\n | { ok: false; reason: 'invalid' | 'throttled' | 'unreachable' };\n\nfunction isOk(result: unknown): boolean {\n return typeof result === 'object' && result !== null && 'ok' in result && result.ok === true;\n}\n\n/** The first issue's message, for a form that has no sentence of its own. */\nfunction invalid(error: z.ZodError): InvalidInput {\n /* v8 ignore next -- defensive: a failed parse always carries at least one issue */\n return { ok: false, reason: 'invalid', message: error.issues[0]?.message ?? 'Invalid input' };\n}\n\n/** The schemas the actions validate with, for an app's own action client. */\nexport function createPortalSchemas(rt: BookingRuntime) {\n const { messages, phone } = rt;\n const nameField = z.string().trim().min(1).max(60);\n\n /**\n * The same rule the wizard applies on the details step, normalised the same\n * way, so a parent who books with one spelling and edits with another stays\n * one contact. `''` clears.\n */\n const phoneField = z.string().transform((value, ctx) => {\n const trimmed = value.trim();\n if (trimmed === '') return null;\n if (!phone.looksValid(trimmed)) {\n ctx.addIssue({ code: 'custom', message: messages.invalidPhone });\n return z.NEVER;\n }\n return phone.nationalDigits(trimmed);\n });\n\n /** Whole, plausible years. */\n const birthYearField = z\n .number()\n .int()\n .refine((year) => year >= 1900 && year <= new Date().getFullYear(), {\n message: messages.invalidBirthYear,\n });\n\n const personFields = z.object({\n name: nameField,\n birth_year: birthYearField,\n birth_month: z.number().int().min(1).max(12).nullable().optional(),\n notes: z.string().trim().max(500).nullable().optional(),\n preferred_resource_id: z\n .string()\n .regex(/^[\\w-]{1,64}$/)\n .nullable()\n .optional(),\n });\n\n /** Which child: the id wherever the profile named one, else the index. */\n const personTarget = z.object({\n person_id: z.string().min(1).max(64).nullable().optional(),\n index: z.number().int().min(0).max(19).optional(),\n });\n\n return {\n startLogin: z.object({ email: emailField }),\n verifyLogin: verifyLoginInput,\n updateProfile: z.object({\n first_name: nameField.optional(),\n last_name: nameField.optional(),\n phone: z.union([z.null(), phoneField]).optional(),\n family: z\n .array(z.object({ name: nameField, birth_year: birthYearField }))\n .max(10)\n .optional(),\n }),\n createPerson: z.object({ person: personFields }),\n updatePerson: personTarget.extend({ person: personFields }),\n savePerson: personTarget.extend({ person: personFields, create: z.boolean() }),\n removePerson: personTarget,\n setMarketingConsent: z.object({ granted: z.boolean() }),\n deleteMe: z.object({ confirm: z.literal(rt.options.portal?.deleteConfirmWord ?? 'SLETT') }),\n };\n}\n\nexport type PortalSchemas = ReturnType<typeof createPortalSchemas>;\n\n/** An empty string is «cleared» for the optional text fields (absent is the caller's). */\nfunction blankIsNull(value: string | null): string | null {\n return value === null || value === '' ? null : value;\n}\n\nexport function createPortalActions(rt: BookingRuntime) {\n const schemas = createPortalSchemas(rt);\n const { portal, session: jar, vippsLink, nextPath } = rt;\n const configuredLocale = rt.options.portal?.locale;\n const locale = configuredLocale ?? 'no';\n const portalPath = rt.paths.portal ?? rt.config.paths.booking;\n\n /**\n * Run `fn` with the parent's session, or say why not. No cookie and a\n * cookie Medal refuses are one answer, and both clear the cookie. When `fn`\n * comes back `{ ok: true }`, Medal has just slid the session and the cookie\n * is renewed to match — never for a delete (which clears it) or an export.\n */\n async function withPortalSession<T>(\n fn: (session: string) => Promise<T>,\n { renew = true }: { renew?: boolean } = {}\n ): Promise<T | SessionFailure> {\n const session = await jar.readPortalSession();\n if (session === null) {\n await jar.clearPortalSession();\n return { ok: false, reason: 'session' };\n }\n try {\n const result = await fn(session);\n if (renew && isOk(result)) await jar.renewPortalSession(session);\n return result;\n } catch (error) {\n if (error instanceof PortalSessionExpiredError) {\n await jar.clearPortalSession();\n return { ok: false, reason: 'session' };\n }\n throw error;\n }\n }\n\n /** The origin this request arrived on, for an absolute return URL. */\n async function requestOrigin(): Promise<string> {\n return originFromHeaders(await headers(), rt.baseUrl() ?? '');\n }\n\n function personAnswer(result: PortalPersonResult): PersonActionResult {\n revalidatePath(portalPath);\n return {\n ok: true as const,\n profile: result.profile,\n personId: result.personId,\n fallback: result.fallback,\n };\n }\n\n async function asPersonAction(\n run: (session: string) => Promise<PortalPersonResult>\n ): Promise<PersonActionResult> {\n return withPortalSession(async (session) => {\n try {\n return personAnswer(await run(session));\n } catch (error) {\n if (error instanceof PortalValidationError) {\n return { ok: false as const, reason: 'invalid' as const, message: error.message };\n }\n throw error;\n }\n });\n }\n\n function personInput(person: z.infer<PortalSchemas['createPerson']>['person']) {\n return {\n name: person.name,\n birthYear: person.birth_year,\n ...(person.birth_month === undefined ? {} : { birthMonth: person.birth_month }),\n ...(person.notes === undefined ? {} : { notes: blankIsNull(person.notes) }),\n ...(person.preferred_resource_id === undefined\n ? {}\n : { preferredResourceId: blankIsNull(person.preferred_resource_id) }),\n };\n }\n\n return {\n /**\n * Ask for a code. Always «sent» — including when Medal is throttling this\n * address: a different answer would let anyone confirm which parents are\n * customers by asking a few times.\n */\n async startLogin(input: unknown): Promise<{ status: 'sent' } | InvalidInput> {\n const parsed = schemas.startLogin.safeParse(input);\n if (!parsed.success) return invalid(parsed.error);\n try {\n // The seam's own default when the site names no locale, as before the move.\n await (configuredLocale === undefined\n ? portal.startLogin(parsed.data.email)\n : portal.startLogin(parsed.data.email, configuredLocale));\n } catch (error) {\n if (!(error instanceof PortalThrottledError)) throw error;\n }\n return { status: 'sent' as const };\n },\n\n /**\n * Exchange the code for the cookie. Prefer the `login/verify` ROUTE from\n * the browser: an action that sets a cookie makes Next re-render the page\n * it was called from. This is for a page that wants that.\n */\n async verifyLogin(input: unknown): Promise<VerifyLoginResult> {\n const parsed = schemas.verifyLogin.safeParse(input);\n if (!parsed.success) return { ok: false, reason: 'invalid' };\n let issued: Awaited<ReturnType<typeof portal.verifyLogin>>;\n try {\n issued = await portal.verifyLogin(parsed.data.email, parsed.data.code);\n } catch (error) {\n if (error instanceof PortalThrottledError) return { ok: false, reason: 'throttled' };\n rt.logger.error(error, 'Portal login code could not be verified');\n return { ok: false, reason: 'unreachable' };\n }\n if (issued === null) return { ok: false, reason: 'invalid' };\n await jar.writePortalSession(issued.sessionToken, issued.expiresAt);\n return { ok: true, guardian: await rt.guardianFromSession(issued.sessionToken) };\n },\n\n /**\n * Send the parent to Vipps — the `action` of a `<form>` driven by\n * `useActionState`, so `(previous, formData)` and the answer IS the next\n * state; the success path never returns (`redirect()` throws, outside\n * the `try`).\n *\n * The return URL is the Vipps return route on the origin the request\n * arrived on (the canonical `baseUrl` when it names none): a preview host\n * must not send its parents back to production. `next` is where a login\n * that did not start on the portal should land; it is validated and kept\n * in a cookie, written on EVERY attempt (`null` included) so a leftover\n * destination cannot capture an ordinary login.\n */\n async startVipps(_previous: VippsStartState, formData?: FormData): Promise<VippsStartState> {\n const next = formData?.get('next');\n await nextPath.writePortalNextPath(typeof next === 'string' ? next : null);\n const returnUrl = new URL(rt.paths.vippsReturn, await requestOrigin()).toString();\n // This browser's binding for a pending link Medal may offer — minted per\n // attempt, kept in an httpOnly cookie, never in a URL.\n const browserBinding = mintBrowserBinding();\n await vippsLink.writeBrowserBinding(browserBinding);\n let authorizeUrl: string;\n try {\n ({ authorizeUrl } = await portal.startVippsLogin({ returnUrl, browserBinding, locale }));\n } catch (error) {\n // No login went out, so no callback will ever spend this binding.\n await vippsLink.clearBrowserBinding();\n if (error instanceof PortalThrottledError) return { ok: false, reason: 'throttled' };\n if (!(error instanceof PortalVippsUnavailableError)) {\n rt.logger.error(error, 'Vipps login could not be started');\n }\n return { ok: false, reason: 'unavailable' };\n }\n redirect(authorizeUrl);\n },\n\n /**\n * «Link Vipps» on the profile: send a LOGGED-IN parent to Vipps to link it\n * to the profile they are in. The same shape as `startVipps`, with the\n * session as the difference. A Medal without the route is `missing`.\n */\n async startVippsLink(\n _previous: VippsLinkStartState,\n _formData?: FormData\n ): Promise<VippsLinkStartState> {\n const session = await jar.readPortalSession();\n if (session === null) return { ok: false, reason: 'session' };\n const returnUrl = new URL(rt.paths.vippsLinkReturn, await requestOrigin()).toString();\n const browserBinding = mintBrowserBinding();\n await vippsLink.writeBrowserBinding(browserBinding);\n let authorizeUrl: string;\n try {\n const started = await portal.startVippsLink(session, {\n returnUrl,\n browserBinding,\n locale,\n });\n if (started === null) {\n await vippsLink.clearBrowserBinding();\n return { ok: false, reason: 'missing' };\n }\n authorizeUrl = started.authorizeUrl;\n } catch (error) {\n await vippsLink.clearBrowserBinding();\n if (error instanceof PortalSessionExpiredError) {\n await jar.clearPortalSession();\n return { ok: false, reason: 'session' };\n }\n if (error instanceof PortalThrottledError) return { ok: false, reason: 'throttled' };\n if (!(error instanceof PortalVippsUnavailableError)) {\n rt.logger.error(error, 'Vipps link could not be started');\n }\n return { ok: false, reason: 'unavailable' };\n }\n redirect(authorizeUrl);\n },\n\n /**\n * Log out. The cookie is cleared whatever Medal says, but only AFTER the\n * revocation is attempted. A revocation that fails is swallowed, and not\n * logged: the only thing worth saying about it would name the session.\n */\n async logout(): Promise<{ ok: true }> {\n const session = await jar.readPortalSession();\n if (session !== null) {\n try {\n await portal.logout(session);\n } catch {\n // Swallowed on purpose; see above.\n }\n }\n await jar.clearPortalSession();\n return { ok: true as const };\n },\n\n /**\n * Edit name, phone and children. The patch carries only the keys the\n * parent sent. Every successful write revalidates the portal page, so Back\n * does not show the values from before the save.\n */\n async updateProfile(input: unknown): Promise<UpdateProfileResult> {\n const parsed = schemas.updateProfile.safeParse(input);\n if (!parsed.success) return invalid(parsed.error);\n return withPortalSession(async (session) => {\n try {\n const profile = await portal.updateMe(session, parsed.data);\n revalidatePath(portalPath);\n return { ok: true as const, profile };\n } catch (error) {\n if (error instanceof PortalValidationError) {\n return { ok: false as const, reason: 'invalid' as const, message: error.message };\n }\n throw error;\n }\n });\n },\n\n /** Add one child (the person endpoints, else the whole-list fallback). */\n async createPerson(input: unknown): Promise<PersonActionResult> {\n const parsed = schemas.createPerson.safeParse(input);\n if (!parsed.success) return invalid(parsed.error);\n const person = personInput(parsed.data.person);\n return asPersonAction((session) => portal.createPerson(session, person));\n },\n\n /** Edit one child in place — the id survives a rename. */\n async updatePerson(input: unknown): Promise<PersonActionResult> {\n const parsed = schemas.updatePerson.safeParse(input);\n if (!parsed.success) return invalid(parsed.error);\n const person = personInput(parsed.data.person);\n const target = { personId: parsed.data.person_id, index: parsed.data.index };\n return asPersonAction((session) => portal.updatePerson(session, target, person));\n },\n\n /** `createPerson` or `updatePerson`, by the `create` flag — one form, one action. */\n async savePerson(input: unknown): Promise<PersonActionResult> {\n const parsed = schemas.savePerson.safeParse(input);\n if (!parsed.success) return invalid(parsed.error);\n const person = personInput(parsed.data.person);\n return asPersonAction((session) =>\n parsed.data.create\n ? portal.createPerson(session, person)\n : portal.updatePerson(\n session,\n { personId: parsed.data.person_id, index: parsed.data.index },\n person\n )\n );\n },\n\n /** Remove one child. Medal deactivates the person; its booking history stays. */\n async removePerson(input: unknown): Promise<PersonActionResult> {\n const parsed = schemas.removePerson.safeParse(input);\n if (!parsed.success) return invalid(parsed.error);\n const target = { personId: parsed.data.person_id, index: parsed.data.index };\n return asPersonAction((session) => portal.removePerson(session, target));\n },\n\n /**\n * The marketing box on the profile, through the same profile patch, and\n * echoing the STORED value so the checkbox shows what Medal has.\n */\n async setMarketingConsent(\n input: unknown\n ): Promise<{ ok: true; marketingConsent: boolean } | SessionFailure | InvalidInput> {\n const parsed = schemas.setMarketingConsent.safeParse(input);\n if (!parsed.success) return invalid(parsed.error);\n return withPortalSession(async (session) => {\n const profile = await portal.updateMe(session, {\n marketing_consent: parsed.data.granted,\n });\n revalidatePath(portalPath);\n return { ok: true as const, marketingConsent: profile.marketingConsent };\n });\n },\n\n /**\n * The parent's data as a file (GDPR art. 15), pretty-printed and dated.\n * No renewal: a cookie set by an action re-renders the page, which would\n * hold the download behind a full dashboard render.\n */\n async exportData(): Promise<{ ok: true; filename: string; json: string } | SessionFailure> {\n return withPortalSession(\n async (session) => {\n const exported = await portal.exportMyData(session);\n const date = new Date().toISOString().slice(0, 10);\n return {\n ok: true as const,\n filename: `${rt.options.portal?.exportFilePrefix ?? 'min-side-eksport'}-${date}.json`,\n json: JSON.stringify(exported, null, 2),\n };\n },\n { renew: false }\n );\n },\n\n /**\n * Delete the account (GDPR art. 17). The confirmation word is checked\n * here rather than trusted from a disabled button. The cookie is cleared\n * only after Medal has said yes.\n */\n async deleteMe(input: unknown): Promise<{ ok: true } | SessionFailure | InvalidInput> {\n const parsed = schemas.deleteMe.safeParse(input);\n if (!parsed.success) return invalid(parsed.error);\n return withPortalSession(\n async (session) => {\n await portal.deleteMe(session);\n await jar.clearPortalSession();\n return { ok: true as const };\n },\n { renew: false }\n );\n },\n };\n}\n\nexport type PortalActions = ReturnType<typeof createPortalActions>;\n\nexport interface Portal {\n /** The session cookie, for a page or route of the app's own. */\n session: BookingRuntime['session'];\n actions: PortalActions;\n schemas: PortalSchemas;\n /** `GET` for the Vipps login return route (`portal.vippsReturnPath`). */\n vippsReturn: { GET(request: Request): Promise<Response> };\n /** `GET` for the «link Vipps» return route (`portal.vippsLinkReturnPath`). */\n vippsLinkReturn: { GET(request: Request): Promise<Response> };\n}\n\nexport function createPortalFor(rt: BookingRuntime): Portal {\n return {\n session: rt.session,\n actions: createPortalActions(rt),\n schemas: createPortalSchemas(rt),\n vippsReturn: { GET: (request) => vippsReturnRoute(rt, request) },\n vippsLinkReturn: { GET: (request) => vippsLinkReturnRoute(rt, request) },\n };\n}\n"],"mappings":"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"}
@@ -0,0 +1,51 @@
1
+ //#region src/next/request.d.ts
2
+ /**
3
+ * The request guards the portal routes share: same-origin checks, a bounded
4
+ * body read and the origin a request arrived on.
5
+ */
6
+ /** The subset of `Headers` these helpers read. Keeps them callable from tests. */
7
+ export type RequestHeaders = {
8
+ get(name: string): string | null;
9
+ };
10
+ /**
11
+ * The origin a request arrived on, from its headers, or `fallback`.
12
+ *
13
+ * For code that runs inside a Server Action, which has no `Request` to read
14
+ * `url` from the way a route handler does. `x-forwarded-host` and
15
+ * `x-forwarded-proto` win over `host` because on Cloudflare and behind Next's
16
+ * own proxying they name the host the browser typed; a bare `host` is the
17
+ * last hop. HTTPS is assumed whenever the proto header is missing and the
18
+ * host is not local — an absolute URL handed to a third party should not
19
+ * guess `http`.
20
+ */
21
+ export declare function originFromHeaders(headers: RequestHeaders, fallback: string): string;
22
+ /**
23
+ * Whether the submission came from a page on somebody else's origin.
24
+ *
25
+ * The threat this answers is a page on another origin POSTing here on a
26
+ * visitor's behalf — for which comparing the declared origin to this site's
27
+ * own is sufficient, because a browser sets `Origin` on a cross-site POST and
28
+ * will not let the page lie about it.
29
+ *
30
+ * A request that declares NEITHER `Origin` nor `Referer` is let through here;
31
+ * the routes that mint a session refuse it on their own (`declaresNoOrigin`).
32
+ *
33
+ * An origin that will not parse — `null` from a sandboxed frame, or a garbled
34
+ * one — is treated as somebody else's. It is not this site's.
35
+ */
36
+ export declare function isCrossOriginRequest(request: Request): boolean;
37
+ /**
38
+ * The request body as text, or `null` if it is over the ceiling.
39
+ *
40
+ * Read in pieces and counted in BYTES, which is the whole point of both
41
+ * halves. `request.text()` buffers everything before anything can judge it,
42
+ * and a decoded string's `.length` is UTF-16 code units, not bytes — so
43
+ * measuring the decoded string against a byte limit lets a multibyte body
44
+ * several times the ceiling straight through.
45
+ *
46
+ * The stream is cancelled the moment the count passes the ceiling, so the
47
+ * remainder is never pulled across.
48
+ */
49
+ export declare function readBoundedText(request: Request, maxBytes: number): Promise<string | null>;
50
+ //#endregion
51
+ //# sourceMappingURL=request.d.mts.map