From 8e0d933cb80ba79720fb494f55974c083f409029 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Fri, 19 Jul 2024 20:32:30 +0100 Subject: [PATCH 01/45] Reverting handlebars --- bun.lockb | Bin 341842 -> 340683 bytes package.json | 1 - src/authentication/check-your-mail.ts | 5 +- src/authentication/log-in-page.ts | 2 +- src/commands/area/create-form.ts | 2 +- src/commands/equipment/add-form.ts | 2 +- .../equipment/register-training-sheet-form.ts | 2 +- .../link-number-to-email-form.ts | 2 +- src/commands/members/edit-name-form.ts | 2 +- src/commands/members/edit-pronouns-form.ts | 2 +- src/commands/super-user/declare-form.ts | 2 +- src/commands/super-user/revoke-form.ts | 2 +- src/commands/trainers/add-trainer-form.ts | 2 +- .../trainers/mark-member-trained-form.ts | 2 +- src/http/email-handler.ts | 2 +- src/queries/all-equipment/render.ts | 2 +- src/queries/area/render.ts | 2 +- src/queries/areas/render.ts | 2 +- src/queries/equipment/render.ts | 2 +- src/queries/failed-imports/render.ts | 2 +- src/queries/landing/render.ts | 2 +- src/queries/log/render.ts | 2 +- src/queries/member/render.ts | 2 +- src/queries/members/render.ts | 2 +- src/queries/super-users/render.ts | 2 +- src/templates/avatar.ts | 86 +++++++-------- src/templates/detail.ts | 2 +- src/templates/grid-js.ts | 2 +- src/templates/head.ts | 99 +++++++++--------- src/templates/logged-in-user-square.ts | 2 +- src/templates/member-input.ts | 2 +- src/templates/navbar.ts | 2 +- src/templates/oops.ts | 2 +- src/templates/page-template.ts | 36 ++----- src/types/display-date.ts | 2 +- src/types/html.ts | 2 +- src/types/member-number.ts | 2 +- 37 files changed, 126 insertions(+), 163 deletions(-) diff --git a/bun.lockb b/bun.lockb index 025ef6cf1fbc3032cf9c8d31e84abdacc6133e97..45fef8e1c61b44ac5e91e02236217f664cd5a400 100755 GIT binary patch delta 61224 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

J_}btH<3V~CR_~5 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

e!e;BhET`wT?=TjA8&|%NJ+yDiur$?s#536 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%0D9q5wVorh%?N54hh`>6?I_IX7YJcWbTy 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 qIqhXOfVDpCheck your mail

If {{submittedEmailAddress}} is linked to a Makespace number you diff --git a/src/authentication/log-in-page.ts b/src/authentication/log-in-page.ts index a514fab1..61bf20c1 100644 --- a/src/authentication/log-in-page.ts +++ b/src/authentication/log-in-page.ts @@ -1,4 +1,4 @@ -import Handlebars, {SafeString} from 'handlebars'; + import {isolatedPageTemplate} from '../templates/page-template'; const LOGIN_PAGE_TEMPLATE = Handlebars.compile( diff --git a/src/commands/area/create-form.ts b/src/commands/area/create-form.ts index 4eb727b7..db9b46df 100644 --- a/src/commands/area/create-form.ts +++ b/src/commands/area/create-form.ts @@ -3,7 +3,7 @@ import {pageTemplate} from '../../templates'; import {User} from '../../types'; import {v4} from 'uuid'; import {Form} from '../../types/form'; -import Handlebars, {SafeString} from 'handlebars'; + type ViewModel = { user: User; diff --git a/src/commands/equipment/add-form.ts b/src/commands/equipment/add-form.ts index 3d7672a5..65d863a0 100644 --- a/src/commands/equipment/add-form.ts +++ b/src/commands/equipment/add-form.ts @@ -9,7 +9,7 @@ import {formatValidationErrors} from 'io-ts-reporters'; import {failureWithStatus} from '../../types/failure-with-status'; import {StatusCodes} from 'http-status-codes'; import {readModels} from '../../read-models'; -import Handlebars, {SafeString} from 'handlebars'; + type ViewModel = { user: User; diff --git a/src/commands/equipment/register-training-sheet-form.ts b/src/commands/equipment/register-training-sheet-form.ts index f8c52728..323e1ff9 100644 --- a/src/commands/equipment/register-training-sheet-form.ts +++ b/src/commands/equipment/register-training-sheet-form.ts @@ -5,7 +5,7 @@ import {Form} from '../../types/form'; import {pageTemplate} from '../../templates'; import {getEquipmentName} from './get-equipment-name'; import {getEquipmentIdFromForm} from './get-equipment-id-from-form'; -import Handlebars, {SafeString} from 'handlebars'; + type ViewModel = { user: User; diff --git a/src/commands/member-numbers/link-number-to-email-form.ts b/src/commands/member-numbers/link-number-to-email-form.ts index 5b0d8af9..15e69528 100644 --- a/src/commands/member-numbers/link-number-to-email-form.ts +++ b/src/commands/member-numbers/link-number-to-email-form.ts @@ -2,7 +2,7 @@ import * as E from 'fp-ts/Either'; import {pageTemplate} from '../../templates'; import {User} from '../../types'; import {Form} from '../../types/form'; -import Handlebars, {SafeString} from 'handlebars'; + type ViewModel = { user: User; diff --git a/src/commands/members/edit-name-form.ts b/src/commands/members/edit-name-form.ts index e30282ab..c299404f 100644 --- a/src/commands/members/edit-name-form.ts +++ b/src/commands/members/edit-name-form.ts @@ -8,7 +8,7 @@ import * as tt from 'io-ts-types'; import {formatValidationErrors} from 'io-ts-reporters'; import {failureWithStatus} from '../../types/failure-with-status'; import {StatusCodes} from 'http-status-codes'; -import Handlebars, {SafeString} from 'handlebars'; + type ViewModel = { user: User; diff --git a/src/commands/members/edit-pronouns-form.ts b/src/commands/members/edit-pronouns-form.ts index ffa77a3c..0090187e 100644 --- a/src/commands/members/edit-pronouns-form.ts +++ b/src/commands/members/edit-pronouns-form.ts @@ -8,7 +8,7 @@ import * as tt from 'io-ts-types'; import {formatValidationErrors} from 'io-ts-reporters'; import {failureWithStatus} from '../../types/failure-with-status'; import {StatusCodes} from 'http-status-codes'; -import Handlebars, {SafeString} from 'handlebars'; + type ViewModel = { user: User; diff --git a/src/commands/super-user/declare-form.ts b/src/commands/super-user/declare-form.ts index e62f787f..df5c4bce 100644 --- a/src/commands/super-user/declare-form.ts +++ b/src/commands/super-user/declare-form.ts @@ -3,7 +3,7 @@ import {pipe} from 'fp-ts/lib/function'; import {pageTemplate} from '../../templates'; import {User, MemberDetails} from '../../types'; import {Form} from '../../types/form'; -import Handlebars, {SafeString} from 'handlebars'; + import {readModels} from '../../read-models'; type ViewModel = { diff --git a/src/commands/super-user/revoke-form.ts b/src/commands/super-user/revoke-form.ts index 0380efb3..06881f7f 100644 --- a/src/commands/super-user/revoke-form.ts +++ b/src/commands/super-user/revoke-form.ts @@ -8,7 +8,7 @@ import {failureWithStatus} from '../../types/failure-with-status'; import {StatusCodes} from 'http-status-codes'; import {formatValidationErrors} from 'io-ts-reporters'; import {Form} from '../../types/form'; -import Handlebars, {SafeString} from 'handlebars'; + type ViewModel = { user: User; diff --git a/src/commands/trainers/add-trainer-form.ts b/src/commands/trainers/add-trainer-form.ts index 1a58539d..ccbdf310 100644 --- a/src/commands/trainers/add-trainer-form.ts +++ b/src/commands/trainers/add-trainer-form.ts @@ -8,7 +8,7 @@ import {formatValidationErrors} from 'io-ts-reporters'; import {failureWithStatus} from '../../types/failure-with-status'; import {StatusCodes} from 'http-status-codes'; import {readModels} from '../../read-models'; -import Handlebars, {SafeString} from 'handlebars'; + type ViewModel = { user: User; diff --git a/src/commands/trainers/mark-member-trained-form.ts b/src/commands/trainers/mark-member-trained-form.ts index 1a32596c..86757eac 100644 --- a/src/commands/trainers/mark-member-trained-form.ts +++ b/src/commands/trainers/mark-member-trained-form.ts @@ -1,4 +1,4 @@ -import Handlebars, {SafeString} from 'handlebars'; + import {pipe} from 'fp-ts/lib/function'; import * as E from 'fp-ts/Either'; import {User, MemberDetails} from '../../types'; diff --git a/src/http/email-handler.ts b/src/http/email-handler.ts index e817469f..1d5bd39a 100644 --- a/src/http/email-handler.ts +++ b/src/http/email-handler.ts @@ -13,7 +13,7 @@ import * as E from 'fp-ts/Either'; import {formatValidationErrors} from 'io-ts-reporters'; import {SendEmail} from '../commands'; import {Config} from '../configuration'; -import Handlebars, {SafeString} from 'handlebars'; + import {isolatedPageTemplate} from '../templates/page-template'; const getActorFrom = (session: unknown, deps: Dependencies) => diff --git a/src/queries/all-equipment/render.ts b/src/queries/all-equipment/render.ts index cddd8d39..d840868f 100644 --- a/src/queries/all-equipment/render.ts +++ b/src/queries/all-equipment/render.ts @@ -1,6 +1,6 @@ import {ViewModel} from './view-model'; import {pageTemplate} from '../../templates'; -import Handlebars, {SafeString} from 'handlebars'; + Handlebars.registerPartial( 'render_equipment_table', diff --git a/src/queries/area/render.ts b/src/queries/area/render.ts index 87222981..b9e28e8b 100644 --- a/src/queries/area/render.ts +++ b/src/queries/area/render.ts @@ -1,6 +1,6 @@ import {ViewModel} from './view-model'; import {pageTemplate} from '../../templates'; -import Handlebars, {SafeString} from 'handlebars'; + Handlebars.registerPartial( 'owners_list', diff --git a/src/queries/areas/render.ts b/src/queries/areas/render.ts index 72223829..224aa7e9 100644 --- a/src/queries/areas/render.ts +++ b/src/queries/areas/render.ts @@ -1,6 +1,6 @@ import {ViewModel} from './view-model'; import {pageTemplate} from '../../templates'; -import Handlebars, {SafeString} from 'handlebars'; + Handlebars.registerPartial( 'areas_table', diff --git a/src/queries/equipment/render.ts b/src/queries/equipment/render.ts index 207b9bd0..a9de7723 100644 --- a/src/queries/equipment/render.ts +++ b/src/queries/equipment/render.ts @@ -1,6 +1,6 @@ import {pageTemplate} from '../../templates'; import {ViewModel} from './view-model'; -import Handlebars, {SafeString} from 'handlebars'; + Handlebars.registerPartial( 'trainers_list', diff --git a/src/queries/failed-imports/render.ts b/src/queries/failed-imports/render.ts index 2e2dd5b6..70088293 100644 --- a/src/queries/failed-imports/render.ts +++ b/src/queries/failed-imports/render.ts @@ -1,6 +1,6 @@ import {ViewModel} from './view-model'; import {pageTemplate} from '../../templates'; -import Handlebars, {SafeString} from 'handlebars'; + Handlebars.registerPartial( 'failed_imports_list', diff --git a/src/queries/landing/render.ts b/src/queries/landing/render.ts index 692fa45d..eab46d15 100644 --- a/src/queries/landing/render.ts +++ b/src/queries/landing/render.ts @@ -1,6 +1,6 @@ import {ViewModel} from './view-model'; import {pageTemplate} from '../../templates'; -import Handlebars, {SafeString} from 'handlebars'; + Handlebars.registerPartial( 'landing_page_member_details', diff --git a/src/queries/log/render.ts b/src/queries/log/render.ts index 84838ae4..4bff6675 100644 --- a/src/queries/log/render.ts +++ b/src/queries/log/render.ts @@ -4,7 +4,7 @@ import {Actor} from '../../types/actor'; import {DomainEvent} from '../../types'; import {inspect} from 'node:util'; import {pageTemplate} from '../../templates'; -import Handlebars, {SafeString} from 'handlebars'; + Handlebars.registerHelper('render_actor', (actor: Actor) => { switch (actor.tag) { diff --git a/src/queries/member/render.ts b/src/queries/member/render.ts index b281031a..6cc31e11 100644 --- a/src/queries/member/render.ts +++ b/src/queries/member/render.ts @@ -1,4 +1,4 @@ -import Handlebars, {SafeString} from 'handlebars'; + import {pageTemplate} from '../../templates'; import {ViewModel} from './view-model'; diff --git a/src/queries/members/render.ts b/src/queries/members/render.ts index c661fc78..0b850699 100644 --- a/src/queries/members/render.ts +++ b/src/queries/members/render.ts @@ -1,6 +1,6 @@ import {pageTemplate} from '../../templates'; import {ViewModel} from './view-model'; -import Handlebars, {SafeString} from 'handlebars'; + Handlebars.registerPartial( 'render_members', diff --git a/src/queries/super-users/render.ts b/src/queries/super-users/render.ts index 5a6741e0..da60909e 100644 --- a/src/queries/super-users/render.ts +++ b/src/queries/super-users/render.ts @@ -1,6 +1,6 @@ import {ViewModel} from './view-model'; import {pageTemplate} from '../../templates'; -import Handlebars, {SafeString} from 'handlebars'; + Handlebars.registerPartial( 'super_users_table', diff --git a/src/templates/avatar.ts b/src/templates/avatar.ts index 14df1b06..048c57fe 100644 --- a/src/templates/avatar.ts +++ b/src/templates/avatar.ts @@ -1,5 +1,5 @@ import {createHash} from 'crypto'; -import Handlebars from 'handlebars'; +import {html, safe} from '../types/html'; function getGravatarUrl(email: string, size: number = 160) { const trimmedEmail = email.trim().toLowerCase(); @@ -7,55 +7,43 @@ function getGravatarUrl(email: string, size: number = 160) { return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=identicon`; } -const AVATAR_THUMBNAIL_TEMPLATE = Handlebars.compile( - ` - The avatar of {{memberNumber}} - ` -); +type GravatarViewModel = { + url1x: string; + url2x: string; + url4x: string; + memberNumber: number; +}; -const AVATAR_PROFILE_TEMPLATE = Handlebars.compile( - ` +const gravatar = + (width: number, height: number) => (viewModel: GravatarViewModel) => html` The avatar of {{memberNumber}} - ` -); + `; -export const registerAvatarHelpers = () => { - Handlebars.registerHelper( - 'avatar_thumbnail', - (email: string, memberNumber: number) => { - return new Handlebars.SafeString( - AVATAR_THUMBNAIL_TEMPLATE({ - url1x: getGravatarUrl(email, 40), - url2x: getGravatarUrl(email, 80), - url4x: getGravatarUrl(email, 160), - memberNumber, - }) - ); - } - ); - Handlebars.registerHelper( - 'avatar_large', - (email: string, memberNumber: number) => { - return new Handlebars.SafeString( - AVATAR_PROFILE_TEMPLATE({ - url1x: getGravatarUrl(email, 320), - url2x: getGravatarUrl(email, 640), - url4x: getGravatarUrl(email, 1280), - memberNumber, - }) - ); - } - ); -}; +const avatarThumbnail = gravatar(40, 40); +const avatarProfile = gravatar(320, 320); + +export const getGravatarThumbnail = (email: string, memberNumber: number) => + avatarThumbnail({ + url1x: getGravatarUrl(email, 40), + url2x: getGravatarUrl(email, 80), + url4x: getGravatarUrl(email, 160), + memberNumber, + }); + +export const getGravatarProfile = (email: string, memberNumber: number) => + avatarProfile({ + url1x: getGravatarUrl(email, 320), + url2x: getGravatarUrl(email, 640), + url4x: getGravatarUrl(email, 1280), + memberNumber, + }); diff --git a/src/templates/detail.ts b/src/templates/detail.ts index 167578ff..5eea791f 100644 --- a/src/templates/detail.ts +++ b/src/templates/detail.ts @@ -1,4 +1,4 @@ -import Handlebars from 'handlebars'; + import * as O from 'fp-ts/Option'; export const registerOptionalDetailHelper = () => { diff --git a/src/templates/grid-js.ts b/src/templates/grid-js.ts index 36fb9cf7..4987dbee 100644 --- a/src/templates/grid-js.ts +++ b/src/templates/grid-js.ts @@ -1,4 +1,4 @@ -import Handlebars from 'handlebars'; + export const registerGridJs = () => { Handlebars.registerPartial( diff --git a/src/templates/head.ts b/src/templates/head.ts index 89bec5c4..ea62270b 100644 --- a/src/templates/head.ts +++ b/src/templates/head.ts @@ -1,53 +1,48 @@ -import Handlebars from 'handlebars'; +import {html, sanitizeString} from '../types/html'; -export const registerHead = () => { - Handlebars.registerPartial( - 'head', - ` - - - - - {{title}} | Cambridge Makespace - - - - - - - - - - - - - - - - ` - ); -}; +export const renderHead = (viewModel: {title: string}) => html` + + + + + ${sanitizeString(viewModel.title)} | Cambridge Makespace + + + + + + + + + + + + + + + +`; diff --git a/src/templates/logged-in-user-square.ts b/src/templates/logged-in-user-square.ts index 6819ad9b..309f158d 100644 --- a/src/templates/logged-in-user-square.ts +++ b/src/templates/logged-in-user-square.ts @@ -1,4 +1,4 @@ -import Handlebars from 'handlebars'; + export const registerLoggedInUserSquare = () => { Handlebars.registerPartial( diff --git a/src/templates/member-input.ts b/src/templates/member-input.ts index 326f128a..dbbd4e13 100644 --- a/src/templates/member-input.ts +++ b/src/templates/member-input.ts @@ -1,4 +1,4 @@ -import Handlebars from 'handlebars'; + export const registerMemberInput = () => { Handlebars.registerPartial( diff --git a/src/templates/navbar.ts b/src/templates/navbar.ts index 1151118b..c8ce1fe4 100644 --- a/src/templates/navbar.ts +++ b/src/templates/navbar.ts @@ -1,4 +1,4 @@ -import Handlebars from 'handlebars'; + export const registerNavBar = () => { Handlebars.registerPartial( diff --git a/src/templates/oops.ts b/src/templates/oops.ts index e7c5a41d..8dfb2c69 100644 --- a/src/templates/oops.ts +++ b/src/templates/oops.ts @@ -1,4 +1,4 @@ -import Handlebars, {SafeString} from 'handlebars'; + const OOPS_PAGE_TEMPLATE = Handlebars.compile( ` diff --git a/src/templates/page-template.ts b/src/templates/page-template.ts index 4d792050..163a922a 100644 --- a/src/templates/page-template.ts +++ b/src/templates/page-template.ts @@ -1,29 +1,11 @@ import {HttpResponse, Member} from '../types'; -import Handlebars, {SafeString} from 'handlebars'; -import {registerHead} from './head'; -import {registerNavBar} from './navbar'; -import {registerAvatarHelpers} from './avatar'; -import {registerGridJs} from './grid-js'; -import {registerFilterListHelper} from './filter-list'; -import {registerMemberInput} from './member-input'; -import {registerOptionalDetailHelper} from './detail'; -import {registerMemberNumberHelper} from '../types/member-number'; -import {registerDisplayDateHelper} from '../types/display-date'; -import {registerLoggedInUserSquare} from './logged-in-user-square'; -import {Html} from '../types/html'; -registerNavBar(); -registerHead(); -registerAvatarHelpers(); -registerOptionalDetailHelper(); -registerMemberNumberHelper(); -registerDisplayDateHelper(); -registerGridJs(); -registerFilterListHelper(); -registerMemberInput(); -registerLoggedInUserSquare(); +import {html, Html} from '../types/html'; -const PAGE_TEMPLATE = Handlebars.compile(` + + + +const PAGE_TEMPLATE = html` {{> head }} @@ -35,10 +17,10 @@ const PAGE_TEMPLATE = Handlebars.compile(` {{> gridjs }} -`); +`; // For pages not part of the normal flow. -const ISOLATED_PAGE_TEMPLATE = Handlebars.compile(` +const ISOLATED_PAGE_TEMPLATE = html` {{> head }} @@ -47,7 +29,7 @@ const ISOLATED_PAGE_TEMPLATE = Handlebars.compile(` {{> gridjs }} -`); +`; export const pageTemplate = (title: string, user: Member) => (body: SafeString) => @@ -67,7 +49,7 @@ export const pageTemplateHandlebarlessBody = navbarRequired: true, }); -export const isolatedPageTemplate = (title: string) => (body: SafeString) => +export const isolatedPageTemplate = (title: string) => (body: Html) => ISOLATED_PAGE_TEMPLATE({ title, body, diff --git a/src/types/display-date.ts b/src/types/display-date.ts index 49d61092..e7bf4891 100644 --- a/src/types/display-date.ts +++ b/src/types/display-date.ts @@ -1,4 +1,4 @@ -import Handlebars from 'handlebars'; + import {DateTime} from 'luxon'; export const registerDisplayDateHelper = () => { diff --git a/src/types/html.ts b/src/types/html.ts index e27f02a0..9ac0ec65 100644 --- a/src/types/html.ts +++ b/src/types/html.ts @@ -32,7 +32,7 @@ export const html = ( }; interface Page { - html: string; + html: Html; } interface Redirect { diff --git a/src/types/member-number.ts b/src/types/member-number.ts index ee2d7473..62fedde3 100644 --- a/src/types/member-number.ts +++ b/src/types/member-number.ts @@ -1,4 +1,4 @@ -import Handlebars from 'handlebars'; + export const registerMemberNumberHelper = () => { Handlebars.registerHelper('member_number', memberNumber => { From bb47c8c3d484ad7ec97560320ec8bd2f58c1f3a1 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Fri, 19 Jul 2024 22:12:48 +0100 Subject: [PATCH 02/45] member input selector back to html template --- src/templates/detail.ts | 33 ---------------------- src/templates/filter-list.ts | 53 ++++++++++++++++------------------- src/templates/member-input.ts | 52 +++++++++++++++++++--------------- src/types/html.ts | 10 +++++++ 4 files changed, 63 insertions(+), 85 deletions(-) delete mode 100644 src/templates/detail.ts diff --git a/src/templates/detail.ts b/src/templates/detail.ts deleted file mode 100644 index 5eea791f..00000000 --- a/src/templates/detail.ts +++ /dev/null @@ -1,33 +0,0 @@ - -import * as O from 'fp-ts/Option'; - -export const registerOptionalDetailHelper = () => { - Handlebars.registerHelper('optional_detail', (data: unknown) => { - if (data !== null) { - switch (typeof data) { - case 'string': - case 'bigint': - case 'boolean': - return data; - case 'number': - if (!isNaN(data)) { - return data; - } - break; - case 'symbol': - case 'undefined': - case 'function': - break; - case 'object': - // Assume its an optional. - if ( - 'value' in data && - O.isSome(data as unknown as O.Option) - ) { - return data.value; - } - } - } - return '—'; - }); -}; diff --git a/src/templates/filter-list.ts b/src/templates/filter-list.ts index 99980495..65590a2f 100644 --- a/src/templates/filter-list.ts +++ b/src/templates/filter-list.ts @@ -1,30 +1,25 @@ -import Handlebars, {HelperOptions} from 'handlebars'; +import {html, Html, joinHtml, sanitizeString} from '../types/html'; -export const registerFilterListHelper = () => { - Handlebars.registerHelper( - 'filterList', - (context: ReadonlyArray, name: string, options: HelperOptions) => { - const prefix = ` - - - - - - - - `; - - const suffix = ` - -
${name}
- `; - - const rows = []; - for (const item of context) { - rows.push(`${options.fn(item)}`); - } - - return prefix + rows.join('') + suffix; - } - ); -}; +export const filterList = ( + context: ReadonlyArray, + name: string, + elementTemplate: (element: T) => Html +): Html => html` + + + + + + + + ${joinHtml( + context.map( + item => + html` + + ` + ) + )} + +
${sanitizeString(name)}
${elementTemplate(item)}
+`; diff --git a/src/templates/member-input.ts b/src/templates/member-input.ts index dbbd4e13..54483f4f 100644 --- a/src/templates/member-input.ts +++ b/src/templates/member-input.ts @@ -1,25 +1,31 @@ +import {MemberDetails} from '../types'; +import {html, Html, optionalSafe, sanitizeString} from '../types/html'; +import {getGravatarThumbnail} from './avatar'; +import {filterList} from './filter-list'; +export const memberInputSelector = (member: MemberDetails): Html => html` +

+ + +
+`; -export const registerMemberInput = () => { - Handlebars.registerPartial( - 'memberInput', - ` -
- Select a member: - {{#filterList this "Members"}} -
- - -
- {{/filterList}} -
- ` - ); -}; +export const memberInput = ( + members: ReadonlyArray +): Html => html` +
+ Select a member: + ${filterList(members, 'Members', memberInputSelector)} +
+`; diff --git a/src/types/html.ts b/src/types/html.ts index 9ac0ec65..5aa67d6d 100644 --- a/src/types/html.ts +++ b/src/types/html.ts @@ -1,4 +1,5 @@ import * as Sum from '@unsplash/sum-types'; +import * as O from 'fp-ts/Option'; import sanitize from 'sanitize-html'; export type Html = string & {readonly Html: unique symbol}; @@ -31,6 +32,15 @@ export const html = ( return result as Html; }; +export const optionalSafe = ( + data: O.Option +): Safe | SanitizedString | number => + O.isSome(data) + ? typeof data.value === 'string' + ? sanitizeString(data.value) + : data.value + : safe('-'); + interface Page { html: Html; } From 0d72cdc5234d73b4dbdd06bffbbc4c97272917ed Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Fri, 19 Jul 2024 22:34:02 +0100 Subject: [PATCH 03/45] Template helpers back to using template strings --- src/commands/area/add-owner-form.ts | 3 +- .../members/sign-owner-agreement-form.ts | 4 +- src/commands/super-user/declare-form.ts | 26 ++++---- src/templates/grid-js.ts | 50 +++++++-------- src/templates/head.ts | 4 +- src/templates/logged-in-user-square.ts | 19 +++--- src/templates/navbar.ts | 43 ++++++------- src/templates/oops.ts | 13 ++-- src/templates/page-template.ts | 61 ++++++------------- 9 files changed, 88 insertions(+), 135 deletions(-) diff --git a/src/commands/area/add-owner-form.ts b/src/commands/area/add-owner-form.ts index 3380c274..cd88c886 100644 --- a/src/commands/area/add-owner-form.ts +++ b/src/commands/area/add-owner-form.ts @@ -13,7 +13,6 @@ import { import {Form} from '../../types/form'; import {AreaOwners} from '../../read-models/members/get-potential-owners'; import {readModels} from '../../read-models'; -import {pageTemplateHandlebarlessBody} from '../../templates/page-template'; import {html, joinHtml, safe, sanitizeString} from '../../types/html'; import {Member} from '../../read-models/members/member'; @@ -111,7 +110,7 @@ const renderForm = (viewModel: ViewModel) => pipe( viewModel, renderBody, - pageTemplateHandlebarlessBody('Add Owner', viewModel.user) + pageTemplate('Add Owner', viewModel.user) ); const paramsCodec = t.strict({ diff --git a/src/commands/members/sign-owner-agreement-form.ts b/src/commands/members/sign-owner-agreement-form.ts index 679eb418..bb2fbd36 100644 --- a/src/commands/members/sign-owner-agreement-form.ts +++ b/src/commands/members/sign-owner-agreement-form.ts @@ -1,7 +1,7 @@ import * as E from 'fp-ts/Either'; import {Form} from '../../types/form'; import {User} from '../../types'; -import {pageTemplateHandlebarlessBody} from '../../templates/page-template'; +import {pageTemplate} from '../../templates/page-template'; import {pipe} from 'fp-ts/lib/function'; import {html, safe} from '../../types/html'; import {ownerAgreement} from './owner-agreement'; @@ -33,7 +33,7 @@ const renderForm = (viewModel: ViewModel) => pipe( viewModel, renderBody, - pageTemplateHandlebarlessBody('Sign Owner Agreement', viewModel.user) + pageTemplate('Sign Owner Agreement', viewModel.user) ); const constructForm: Form['constructForm'] = diff --git a/src/commands/super-user/declare-form.ts b/src/commands/super-user/declare-form.ts index df5c4bce..a6a097ff 100644 --- a/src/commands/super-user/declare-form.ts +++ b/src/commands/super-user/declare-form.ts @@ -5,30 +5,28 @@ import {User, MemberDetails} from '../../types'; import {Form} from '../../types/form'; import {readModels} from '../../read-models'; +import {html} from '../../types/html'; +import {memberInput} from '../../templates/member-input'; type ViewModel = { user: User; members: ReadonlyArray; }; -const RENDER_DECLARE_SUPER_USER_TEMPLATE = Handlebars.compile( - ` -

Declare super user

-
- - {{> memberInput members }} - -
- ` -); - const renderForm = (viewModel: ViewModel) => pageTemplate( 'Declare super user', viewModel.user - )(new SafeString(RENDER_DECLARE_SUPER_USER_TEMPLATE(viewModel))); + )(html` +

Declare super user

+
+ + ${memberInput(viewModel.members)} + +
+ `); const constructForm: Form['constructForm'] = () => diff --git a/src/templates/grid-js.ts b/src/templates/grid-js.ts index 4987dbee..014f9b6d 100644 --- a/src/templates/grid-js.ts +++ b/src/templates/grid-js.ts @@ -1,32 +1,26 @@ +import {html} from '../types/html'; +export const gridJs = () => html` + - ` - ); -}; + const tables = document.querySelectorAll('table[data-gridjs]'); + for (const table of tables) { + initGrid(table); + } + +`; diff --git a/src/templates/head.ts b/src/templates/head.ts index ea62270b..e20e7dcf 100644 --- a/src/templates/head.ts +++ b/src/templates/head.ts @@ -1,11 +1,11 @@ import {html, sanitizeString} from '../types/html'; -export const renderHead = (viewModel: {title: string}) => html` +export const head = (title: string) => html` - ${sanitizeString(viewModel.title)} | Cambridge Makespace + ${sanitizeString(title)} | Cambridge Makespace diff --git a/src/templates/logged-in-user-square.ts b/src/templates/logged-in-user-square.ts index 309f158d..b75ee269 100644 --- a/src/templates/logged-in-user-square.ts +++ b/src/templates/logged-in-user-square.ts @@ -1,12 +1,9 @@ +import {Member} from '../types'; +import {html} from '../types/html'; +import {getGravatarThumbnail} from './avatar'; - -export const registerLoggedInUserSquare = () => { - Handlebars.registerPartial( - 'loggedInUserSquare', - ` - - {{avatar_thumbnail this.emailAddress this.memberNumber}} - - ` - ); -}; +export const loggedInUserSquare = (member: Member) => html` + + ${getGravatarThumbnail(member.emailAddress, member.memberNumber)} + +`; diff --git a/src/templates/navbar.ts b/src/templates/navbar.ts index c8ce1fe4..b8cc787b 100644 --- a/src/templates/navbar.ts +++ b/src/templates/navbar.ts @@ -1,24 +1,21 @@ +import {Member} from '../types'; +import {html} from '../types/html'; +import {loggedInUserSquare} from './logged-in-user-square'; - -export const registerNavBar = () => { - Handlebars.registerPartial( - 'navbar', - ` - - ` - ); -}; +export const navBar = (user: Member) => html` + +`; diff --git a/src/templates/oops.ts b/src/templates/oops.ts index 8dfb2c69..427348bd 100644 --- a/src/templates/oops.ts +++ b/src/templates/oops.ts @@ -1,15 +1,10 @@ +import {html, sanitizeString} from '../types/html'; - -const OOPS_PAGE_TEMPLATE = Handlebars.compile( - ` +export const oopsPage = (message: string) => html`

Sorry, we have encountered a problem

-

{{message}}

+

${sanitizeString(message)}

Please try again. If the problem persists please reach out in the google group.

- ` -); - -export const oopsPage = (message: string | SafeString) => - OOPS_PAGE_TEMPLATE({message}); +`; diff --git a/src/templates/page-template.ts b/src/templates/page-template.ts index 163a922a..27107f35 100644 --- a/src/templates/page-template.ts +++ b/src/templates/page-template.ts @@ -1,60 +1,33 @@ import {HttpResponse, Member} from '../types'; import {html, Html} from '../types/html'; +import {gridJs} from './grid-js'; +import {head} from './head'; +import {navBar} from './navbar'; - - - -const PAGE_TEMPLATE = html` - - - {{> head }} -
- {{> navbar }} -
- - {{body}} - {{> gridjs }} - - -`; +export const pageTemplate = + (title: string, user: Member) => (body: Html) => html` + + + ${head(title)} +
${navBar(user)}
+ + ${body} ${gridJs()} + + + `; // For pages not part of the normal flow. -const ISOLATED_PAGE_TEMPLATE = html` +export const isolatedPageTemplate = (title: string) => (body: Html) => html` - {{> head }} + ${head(title)} - {{body}} - {{> gridjs }} + ${body} ${gridJs()} `; -export const pageTemplate = - (title: string, user: Member) => (body: SafeString) => - PAGE_TEMPLATE({ - title, - user, - body, - navbarRequired: true, - }); - -export const pageTemplateHandlebarlessBody = - (title: string, user: Member) => (body: Html) => - PAGE_TEMPLATE({ - title, - user, - body: new SafeString(body), - navbarRequired: true, - }); - -export const isolatedPageTemplate = (title: string) => (body: Html) => - ISOLATED_PAGE_TEMPLATE({ - title, - body, - }); - export const templatePage: (r: HttpResponse) => HttpResponse = HttpResponse.match({ Redirect: HttpResponse.mk.Redirect, From 447c28497c55b919e69675cf4b5f5a76b80cfd5e Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Fri, 19 Jul 2024 23:38:03 +0100 Subject: [PATCH 04/45] Continuing to switch back to template strings not handlebars --- src/queries/equipment/render.ts | 374 +++++++++++++++----------------- src/templates/display-date.ts | 15 ++ src/templates/member-number.ts | 5 + src/types/display-date.ts | 19 -- src/types/html.ts | 2 +- src/types/member-number.ts | 17 -- 6 files changed, 194 insertions(+), 238 deletions(-) create mode 100644 src/templates/display-date.ts create mode 100644 src/templates/member-number.ts delete mode 100644 src/types/display-date.ts delete mode 100644 src/types/member-number.ts diff --git a/src/queries/equipment/render.ts b/src/queries/equipment/render.ts index a9de7723..31e2c8eb 100644 --- a/src/queries/equipment/render.ts +++ b/src/queries/equipment/render.ts @@ -1,220 +1,192 @@ import {pageTemplate} from '../../templates'; +import {displayDate} from '../../templates/display-date'; +import {renderMemberNumber} from '../../templates/member-number'; +import {html, joinHtml, sanitizeString} from '../../types/html'; import {ViewModel} from './view-model'; +const trainersList = (trainers: ViewModel['equipment']['trainers']) => html` +

Trainers

+
    + ${trainers.length > 0 + ? joinHtml( + trainers.map( + memberNumber => html`
  • ${renderMemberNumber(memberNumber)}
  • ` + ) + ) + : html`

    This equipment needs trainers.

    `} +
+`; -Handlebars.registerPartial( - 'trainers_list', - ` -

Trainers

-
    - {{#each equipment.trainers}} -
  • {{member_number this}}
  • - {{else}} -

    This equipment needs trainers.

    - {{/each}} -
-` -); +const trainerEquipmentActions = (equipment: ViewModel['equipment']) => html` +
  • + Mark member as trained +
  • +`; -Handlebars.registerPartial( - 'trainer_equipment_actions', - ` -{{#with equipment}} -
  • - Mark member as trained -
  • -{{/with}} -` -); +const ownerEquipmentActions = (equipment: ViewModel['equipment']) => html` +
  • + + Add a trainer + +
  • +
  • + + Register training sheet + +
  • +`; -Handlebars.registerPartial( - 'owner_equipment_actions', - ` -{{#with equipment}} -
  • - Add a trainer -
  • -
  • - Register training sheet -
  • -{{/with}} -` -); +const equipmentActions = (viewModel: ViewModel) => html` +
      + ${viewModel.isSuperUserOrOwnerOfArea + ? ownerEquipmentActions(viewModel.equipment) + : html``} + ${viewModel.isSuperUserOrTrainerOfArea + ? trainerEquipmentActions(viewModel.equipment) + : html``} +
    +`; -Handlebars.registerPartial( - 'equipment_actions', - ` -
      -{{#if isSuperUserOrOwnerOfArea}} - {{> owner_equipment_actions }} -{{/if}} -{{#if isSuperUserOrTrainerOfArea}} - {{> trainer_equipment_actions }} -{{/if}} -
    -` -); - -Handlebars.registerPartial( - 'currently_trained_users_table', - ` -

    Currently Trained Users

    - - - - - {{#with equipment}} - {{#each trainedMembers}} - - {{/each}} - {{/with}} -
    Member Number
    {{member_number this}}
    -` -); - -// TODO -// 2. Dates aren't displayed using the users locale. -Handlebars.registerPartial( - 'training_quiz_results_table', - ` - - - - - - - - - {{#each results.knownMember}} - {{#if this.passed}} - - {{else}} - - {{/if}} - - - - - - - - {{else}} -

    {{empty_msg}}

    - {{/each}} -
    TimestampEmailScore
    {{display_date this.timestamp}}{{member_number this.memberNumber}} - {{this.score}} / {{this.maxScore}} ({{this.percentage}}%) -
    -` -); - -Handlebars.registerPartial( - 'training_quiz_results', - ` -

    Training Quiz Results

    -

    Waiting for Training

    - - {{#if trainingQuizResults.quizPassedNotTrained.knownMember}} - - - - - - - - - {{#each trainingQuizResults.quizPassedNotTrained.knownMember}} - - - - - - - - - {{/each}} - {{else}} -

    No one is waiting for training

    - {{/if}} -
    TimestampMember NumberScoreActions
    {{display_date this.timestamp}}{{member_number this.memberNumber}} - {{this.score}} / {{this.maxScore}} ({{this.percentage}}%) -
    -{{#if trainingQuizResults.quizPassedNotTrained.unknownMember}} -

    Waiting for Training - Unknown Member

    -

    - Quizes completed by members without matching email and member numbers. -

    +const currentlyTrainedUsersTable = (viewModel: ViewModel) => html` +

    Currently Trained Users

    - - - - - + - {{#each trainingQuizResults.quizPassedNotTrained.unknownMember}} - - - - {{#if this.memberNumberProvided}} - - {{else}} - - {{/if}} - - - - {{/each}} + ${joinHtml( + viewModel.equipment.trainedMembers.map( + memberNumber => + html` + + ` + ) + )}
    TimestampMember Number ProvidedEmail ProvidedScoreMember Number
    {{display_date this.timestamp}}{{member_number this.memberNumberProvided}}{{optional_detail this.memberNumberProvided}}{{optional_detail this.emailProvided}} - {{this.score}} / {{this.maxScore}} ({{this.percentage}}%) -
    ${renderMemberNumber(memberNumber)}
    -{{/if}} -{{#if trainingQuizResults.failedQuizNotTrained.knownMember}} -

    Failed quizes

    -

    Members who haven't passed (but have attempted) the quiz

    +`; + +const waitingForTrainingTable = (viewModel: ViewModel) => html` - - - - - - - - {{#each trainingQuizResults.failedQuizNotTrained.knownMember}} - - - - - - - - {{/each}} + ${viewModel.trainingQuizResults.quizPassedNotTrained.knownMember.length > 0 + ? html` + + + + + + + + + ${joinHtml( + viewModel.trainingQuizResults.quizPassedNotTrained.knownMember.map( + member => html` + + + + + + + + + ` + ) + )} + ` + : html`

    No one is waiting for training

    `}
    TimestampMember NumberScore
    {{display_date this.timestamp}}{{member_number this.memberNumber}} - {{this.score}} / {{this.maxScore}} ({{this.percentage}}%) -
    TimestampMember NumberScoreActions
    ${displayDate(member.timestamp)}${renderMemberNumber(member.memberNumber)} + ${member.score} / ${member.maxScore}} + (${member.percentage}%) +
    -{{/if}} -` -); +`; + +const unknownMemberWaitingForTrainingTable = (viewModel: ViewModel) => html` + ${viewModel.trainingQuizResults.quizPassedNotTrained.unknownMember.length > 0 + ? html` +

    Waiting for Training - Unknown Member

    +

    + Quizes completed by members without matching email and member numbers. +

    + + + + + + + + + {{#each trainingQuizResults.quizPassedNotTrained.unknownMember}} + + + + {{#if this.memberNumberProvided}} + + {{else}} + + {{/if}} + + + + {{/each}} +
    TimestampMember Number ProvidedEmail ProvidedScore
    {{display_date this.timestamp}}{{member_number this.memberNumberProvided}}{{optional_detail this.memberNumberProvided}}{{optional_detail this.emailProvided}}{{this.score}} / {{this.maxScore}} ({{this.percentage}}%)
    + ` + : html``} +`; + +const failedQuizTrainingTable = (viewModel: ViewModel) => html` + ${viewModel.trainingQuizResults.failedQuizNotTrained.knownMember.length > 0 + ? html` +

    Failed quizes

    +

    Members who haven't passed (but have attempted) the quiz

    + + + + + + + + + {{#each trainingQuizResults.failedQuizNotTrained.knownMember}} + + + + + + + + {{/each}} +
    TimestampMember NumberScore
    {{display_date this.timestamp}}{{member_number this.memberNumber}}{{this.score}} / {{this.maxScore}} ({{this.percentage}}%)
    + ` + : html``} +`; -const RENDER_EQUIPMENT_TEMPLATE = Handlebars.compile( - ` -

    {{equipment_name}}

    - {{> equipment_actions }} - {{> trainers_list }} - {{> currently_trained_users_table }} - {{> training_quiz_results }} - ` -); +const trainingQuizResults = (viewModel: ViewModel) => html` +

    Training Quiz Results

    +

    Waiting for Training

    + ${waitingForTrainingTable(viewModel)} + ${unknownMemberWaitingForTrainingTable(viewModel)} + ${failedQuizTrainingTable(viewModel)} +`; export const render = (viewModel: ViewModel) => pageTemplate( viewModel.equipment.name, viewModel.user - )(new SafeString(RENDER_EQUIPMENT_TEMPLATE(viewModel))); + )(html` +

    ${sanitizeString(viewModel.equipment.name)}

    + ${equipmentActions(viewModel)} ${trainersList(viewModel.equipment.trainers)} + ${currentlyTrainedUsersTable(viewModel)} ${trainingQuizResults(viewModel)} + `); diff --git a/src/templates/display-date.ts b/src/templates/display-date.ts new file mode 100644 index 00000000..8a4802b1 --- /dev/null +++ b/src/templates/display-date.ts @@ -0,0 +1,15 @@ +import {DateTime} from 'luxon'; +import {Safe, safe} from '../types/html'; + +export const displayDate = (date: DateTime | number): Safe => { + // TODO Do this properly. https://github.com/Makespace/members-app/issues/40 + switch (typeof date) { + case 'number': + return safe(DateTime.fromMillis(date).toLocaleString()); + default: + if (date instanceof DateTime) { + return safe(date.toLocaleString()); + } + return safe('Unknown Date'); // Placeholder. + } +}; diff --git a/src/templates/member-number.ts b/src/templates/member-number.ts new file mode 100644 index 00000000..ce209ae1 --- /dev/null +++ b/src/templates/member-number.ts @@ -0,0 +1,5 @@ +import {html} from '../types/html'; + +export const renderMemberNumber = (memberNumber: number) => html` + ${memberNumber} +`; diff --git a/src/types/display-date.ts b/src/types/display-date.ts deleted file mode 100644 index e7bf4891..00000000 --- a/src/types/display-date.ts +++ /dev/null @@ -1,19 +0,0 @@ - -import {DateTime} from 'luxon'; - -export const registerDisplayDateHelper = () => { - Handlebars.registerHelper('display_date', date => { - // TODO Do this properly. https://github.com/Makespace/members-app/issues/40 - switch (typeof date) { - case 'string': - return date; - case 'number': - return DateTime.fromMillis(date).toLocaleString(); - default: - if (date instanceof DateTime || date instanceof Date) { - return date.toLocaleString(); - } - return 'Unknown Date'; // Placeholder. - } - }); -}; diff --git a/src/types/html.ts b/src/types/html.ts index 5aa67d6d..39f26569 100644 --- a/src/types/html.ts +++ b/src/types/html.ts @@ -6,7 +6,7 @@ export type Html = string & {readonly Html: unique symbol}; type SanitizedString = string & {readonly SanitizedString: unique symbol}; -type Safe = string & {readonly Safe: unique symbol}; +export type Safe = string & {readonly Safe: unique symbol}; export const sanitizeString = (input: string): SanitizedString => sanitize(input) as SanitizedString; diff --git a/src/types/member-number.ts b/src/types/member-number.ts deleted file mode 100644 index 62fedde3..00000000 --- a/src/types/member-number.ts +++ /dev/null @@ -1,17 +0,0 @@ - - -export const registerMemberNumberHelper = () => { - Handlebars.registerHelper('member_number', memberNumber => { - // This may not be strictly needed as memberNumber should always be a number but following the approach of escaping everything going to end users. - const escapedMemberNumber = Handlebars.escapeExpression( - memberNumber as string - ); - return new Handlebars.SafeString( - '' + - escapedMemberNumber + - '' - ); - }); -}; From 512511b864ff72c2916782862781c00463565adb Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 09:25:21 +0100 Subject: [PATCH 05/45] Equipment back to templates --- src/queries/equipment/render.ts | 83 ++++++++++++++++++----------- src/queries/equipment/view-model.ts | 5 +- 2 files changed, 56 insertions(+), 32 deletions(-) diff --git a/src/queries/equipment/render.ts b/src/queries/equipment/render.ts index 31e2c8eb..1469c423 100644 --- a/src/queries/equipment/render.ts +++ b/src/queries/equipment/render.ts @@ -1,8 +1,9 @@ import {pageTemplate} from '../../templates'; import {displayDate} from '../../templates/display-date'; import {renderMemberNumber} from '../../templates/member-number'; -import {html, joinHtml, sanitizeString} from '../../types/html'; +import {html, joinHtml, optionalSafe, sanitizeString} from '../../types/html'; import {ViewModel} from './view-model'; +import * as O from 'fp-ts/Option'; const trainersList = (trainers: ViewModel['equipment']['trainers']) => html`

    Trainers

    @@ -87,19 +88,18 @@ const waitingForTrainingTable = (viewModel: ViewModel) => html` ${joinHtml( viewModel.trainingQuizResults.quizPassedNotTrained.knownMember.map( - member => html` + quiz => html` - ${sanitizeString(member.id)} - ${displayDate(member.timestamp)} - ${renderMemberNumber(member.memberNumber)} + ${sanitizeString(quiz.id)} + ${displayDate(quiz.timestamp)} + ${renderMemberNumber(quiz.memberNumber)} - ${member.score} / ${member.maxScore}} - (${member.percentage}%) + ${quiz.score} / ${quiz.maxScore} (${quiz.percentage}%) ${joinHtml( - member.otherAttempts + quiz.otherAttempts .map(sanitizeString) .map(v => html`${v}`) )} @@ -128,19 +128,30 @@ const unknownMemberWaitingForTrainingTable = (viewModel: ViewModel) => html` Email Provided Score - {{#each trainingQuizResults.quizPassedNotTrained.unknownMember}} - - {{this.id}} - {{display_date this.timestamp}} - {{#if this.memberNumberProvided}} - {{member_number this.memberNumberProvided}} - {{else}} - {{optional_detail this.memberNumberProvided}} - {{/if}} - {{optional_detail this.emailProvided}} - {{this.score}} / {{this.maxScore}} ({{this.percentage}}%) - - {{/each}} + ${joinHtml( + viewModel.trainingQuizResults.quizPassedNotTrained.unknownMember.map( + unknownQuiz => html` + + ${sanitizeString(unknownQuiz.id)} + ${displayDate(unknownQuiz.timestamp)} + ${O.isSome(unknownQuiz.memberNumberProvided) + ? html` + ${renderMemberNumber( + unknownQuiz.memberNumberProvided.value + )} + ` + : html` + ${optionalSafe(unknownQuiz.memberNumberProvided)} + `} + ${optionalSafe(unknownQuiz.emailProvided)} + + ${unknownQuiz.score} / ${unknownQuiz.maxScore} + (${unknownQuiz.percentage}%) + + + ` + ) + )} ` : html``} @@ -159,15 +170,27 @@ const failedQuizTrainingTable = (viewModel: ViewModel) => html` Score Other Attempts - {{#each trainingQuizResults.failedQuizNotTrained.knownMember}} - - {{this.id}} - {{display_date this.timestamp}} - {{member_number this.memberNumber}} - {{this.score}} / {{this.maxScore}} ({{this.percentage}}%) - {{this.otherAttempts}} - - {{/each}} + ${joinHtml( + viewModel.trainingQuizResults.failedQuizNotTrained.knownMember.map( + member => html` + + ${sanitizeString(member.id)} + ${displayDate(member.timestamp)} + ${renderMemberNumber(member.memberNumber)} + + ${member.score} / ${member.maxScore} (${member.percentage}%) + + + ${joinHtml( + member.otherAttempts + .map(sanitizeString) + .map(v => html`${v}`) + )} + + + ` + ) + )} ` : html``} diff --git a/src/queries/equipment/view-model.ts b/src/queries/equipment/view-model.ts index fe612e4f..eebb3622 100644 --- a/src/queries/equipment/view-model.ts +++ b/src/queries/equipment/view-model.ts @@ -1,5 +1,6 @@ import {DateTime} from 'luxon'; import {User} from '../../types'; +import * as O from 'fp-ts/Option'; type QuizID = string; @@ -24,8 +25,8 @@ export type QuizResultUnknownMemberViewModel = { passed: boolean; timestamp: DateTime; - memberNumberProvided: number | null; - emailProvided: string | null; + memberNumberProvided: O.Option; + emailProvided: O.Option; }; export type ViewModel = { From d25949b7135663fc2075e80bdbbe70f0e91ea168 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 09:32:04 +0100 Subject: [PATCH 06/45] Revert auth --- src/authentication/check-your-mail.ts | 29 +++++++++------------------ src/authentication/log-in-page.ts | 13 +++--------- 2 files changed, 12 insertions(+), 30 deletions(-) diff --git a/src/authentication/check-your-mail.ts b/src/authentication/check-your-mail.ts index 2dfce81b..08b6675d 100644 --- a/src/authentication/check-your-mail.ts +++ b/src/authentication/check-your-mail.ts @@ -1,21 +1,10 @@ -import {isolatedPageTemplate} from '../templates/page-template'; -import { html } from '../types/html'; +import {html, sanitizeString} from '../types/html'; -const CHECK_YOUR_MAIL_TEMPLATE = html` -

    Check your mail

    -

    - If {{submittedEmailAddress}} is linked to a Makespace number you - should receive an email with that number. -

    -

    If nothing happens please reach out to the Makespace Database Team.

    - ` -); - -export const checkYourMailPage = (submittedEmailAddress: string) => - isolatedPageTemplate('Check your mail')( - new Handlebars.SafeString( - CHECK_YOUR_MAIL_TEMPLATE({ - submittedEmailAddress, - }) - ) - ); +export const checkYourMailPage = (submittedEmailAddress: string) => html` +

    Check your mail

    +

    + If ${sanitizeString(submittedEmailAddress)} is linked to a Makespace + number you should receive an email with that number. +

    +

    If nothing happens please reach out to the Makespace Database Team.

    +`; diff --git a/src/authentication/log-in-page.ts b/src/authentication/log-in-page.ts index 61bf20c1..2439f6df 100644 --- a/src/authentication/log-in-page.ts +++ b/src/authentication/log-in-page.ts @@ -1,8 +1,6 @@ +import {html} from '../types/html'; -import {isolatedPageTemplate} from '../templates/page-template'; - -const LOGIN_PAGE_TEMPLATE = Handlebars.compile( - ` +export const logInPage = () => html`

    Log in

    @@ -10,9 +8,4 @@ const LOGIN_PAGE_TEMPLATE = Handlebars.compile(

    We will email you a magic log in link.

    - ` -); - -export const logInPage = isolatedPageTemplate('MakeSpace Members App')( - new SafeString(LOGIN_PAGE_TEMPLATE({})) -); +`; From 9cc74bd38e9b7895330081d5e71222d1ad60470f Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 09:38:05 +0100 Subject: [PATCH 07/45] area commands -> templates --- src/commands/area/add-owner-form.ts | 7 ++----- src/commands/area/create-form.ts | 28 ++++++++++------------------ 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/src/commands/area/add-owner-form.ts b/src/commands/area/add-owner-form.ts index cd88c886..da8208ed 100644 --- a/src/commands/area/add-owner-form.ts +++ b/src/commands/area/add-owner-form.ts @@ -15,6 +15,7 @@ import {AreaOwners} from '../../read-models/members/get-potential-owners'; import {readModels} from '../../read-models'; import {html, joinHtml, safe, sanitizeString} from '../../types/html'; import {Member} from '../../read-models/members/member'; +import {pageTemplate} from '../../templates'; type ViewModel = { user: User; @@ -107,11 +108,7 @@ const renderBody = (viewModel: ViewModel) => html` `; const renderForm = (viewModel: ViewModel) => - pipe( - viewModel, - renderBody, - pageTemplate('Add Owner', viewModel.user) - ); + pipe(viewModel, renderBody, pageTemplate('Add Owner', viewModel.user)); const paramsCodec = t.strict({ area: t.string, diff --git a/src/commands/area/create-form.ts b/src/commands/area/create-form.ts index db9b46df..fd4d0833 100644 --- a/src/commands/area/create-form.ts +++ b/src/commands/area/create-form.ts @@ -1,36 +1,28 @@ import * as E from 'fp-ts/Either'; import {pageTemplate} from '../../templates'; import {User} from '../../types'; -import {v4} from 'uuid'; import {Form} from '../../types/form'; - +import {pipe} from 'fp-ts/lib/function'; +import {html, safe} from '../../types/html'; +import {v4} from 'uuid'; type ViewModel = { user: User; }; -const CREATE_FORM_TEMPLATE = Handlebars.compile( - ` +const renderForm = (viewModel: ViewModel) => + pipe( + viewModel, + () => html`

    Create an area

    - +
    - ` -); - -const renderForm = (viewModel: ViewModel) => - pageTemplate( - 'Create Area', - viewModel.user - )( - new SafeString( - CREATE_FORM_TEMPLATE({ - areaId: v4(), - }) - ) + `, + pageTemplate('Create Area', viewModel.user) ); export const createForm: Form = { From 70ac0e2de3f358d7c88516bee7146cd11fbca1ff Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 09:43:09 +0100 Subject: [PATCH 08/45] Revert equipment forms to templates --- src/commands/equipment/add-form.ts | 38 +++++++++---------- .../equipment/register-training-sheet-form.ts | 24 ++++++------ 2 files changed, 30 insertions(+), 32 deletions(-) diff --git a/src/commands/equipment/add-form.ts b/src/commands/equipment/add-form.ts index 65d863a0..dd457412 100644 --- a/src/commands/equipment/add-form.ts +++ b/src/commands/equipment/add-form.ts @@ -2,6 +2,7 @@ import {pipe} from 'fp-ts/lib/function'; import * as t from 'io-ts'; import * as E from 'fp-ts/Either'; import {pageTemplate} from '../../templates'; +import {html, safe, sanitizeString} from '../../types/html'; import {DomainEvent, User} from '../../types'; import {v4} from 'uuid'; import {Form} from '../../types/form'; @@ -10,30 +11,30 @@ import {failureWithStatus} from '../../types/failure-with-status'; import {StatusCodes} from 'http-status-codes'; import {readModels} from '../../read-models'; - type ViewModel = { user: User; areaId: string; areaName: string; - newEquipmentId: string; }; -const RENDER_ADD_EQUIPMENT_FORM_TEMPLATE = Handlebars.compile(` -

    Add equipment to {{areaName}}

    -
    - - - - - -
    -`); - const renderForm = (viewModel: ViewModel) => - pageTemplate( - 'Create Equipment', - viewModel.user - )(new SafeString(RENDER_ADD_EQUIPMENT_FORM_TEMPLATE(viewModel))); + pipe( + html` +

    Add equipment to ${sanitizeString(viewModel.areaName)}

    +
    + + + + + +
    + `, + pageTemplate('Create Equipment', viewModel.user) + ); const getAreaId = (input: unknown) => pipe( @@ -61,8 +62,7 @@ const constructForm: Form['constructForm'] = E.Do, E.bind('areaId', () => getAreaId(input)), E.bind('areaName', ({areaId}) => getAreaName(events, areaId)), - E.bind('user', () => E.right(user)), - E.bind('newEquipmentId', () => E.right(v4())) + E.bind('user', () => E.right(user)) ); export const addForm: Form = { diff --git a/src/commands/equipment/register-training-sheet-form.ts b/src/commands/equipment/register-training-sheet-form.ts index 323e1ff9..dfd98d38 100644 --- a/src/commands/equipment/register-training-sheet-form.ts +++ b/src/commands/equipment/register-training-sheet-form.ts @@ -1,39 +1,37 @@ import {pipe} from 'fp-ts/lib/function'; import * as E from 'fp-ts/Either'; +import {html, sanitizeString} from '../../types/html'; import {User} from '../../types'; import {Form} from '../../types/form'; import {pageTemplate} from '../../templates'; import {getEquipmentName} from './get-equipment-name'; import {getEquipmentIdFromForm} from './get-equipment-id-from-form'; - type ViewModel = { user: User; equipmentId: string; equipmentName: string; }; -const RENDER_REGISTER_TRAINING_SHEET_TEMPLATE = Handlebars.compile( - ` -

    Register training sheet for {{equipmentName}}

    +const renderForm = (viewModel: ViewModel) => + pipe( + html` +

    + Register training sheet for ${sanitizeString(viewModel.equipmentName)} +

    - ` -); - -const renderForm = (viewModel: ViewModel) => - pageTemplate( - 'Register training sheet', - viewModel.user - )(new SafeString(RENDER_REGISTER_TRAINING_SHEET_TEMPLATE(viewModel))); + `, + pageTemplate('Register training sheet', viewModel.user) + ); const constructForm: Form['constructForm'] = input => From c1083e0925c112ed9c14ef35a1fe815aa78eee23 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 09:44:12 +0100 Subject: [PATCH 09/45] Revert member number linking form to templates --- .../link-number-to-email-form.ts | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/src/commands/member-numbers/link-number-to-email-form.ts b/src/commands/member-numbers/link-number-to-email-form.ts index 15e69528..a1124d79 100644 --- a/src/commands/member-numbers/link-number-to-email-form.ts +++ b/src/commands/member-numbers/link-number-to-email-form.ts @@ -1,32 +1,31 @@ +import {pipe} from 'fp-ts/lib/function'; import * as E from 'fp-ts/Either'; import {pageTemplate} from '../../templates'; +import {html} from '../../types/html'; import {User} from '../../types'; import {Form} from '../../types/form'; - type ViewModel = { user: User; }; -const RENDER_LINK_NUMBER_TO_EMAIL_TEMPLATE = Handlebars.compile(` -

    Link a member number to an e-mail address

    -
    - - - - - -
    - `); - const renderForm = (viewModel: ViewModel) => - pageTemplate( - 'Link a member number to an e-mail address', - viewModel.user - )(new SafeString(RENDER_LINK_NUMBER_TO_EMAIL_TEMPLATE(viewModel))); + pipe( + html` +

    Link a member number to an e-mail address

    +
    + + + + + +
    + `, + pageTemplate('Link a member number to an e-mail address', viewModel.user) + ); const constructForm: Form['constructForm'] = () => From fdcdee2a87fec31bd310010ff7dbdd9afa818377 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 10:22:05 +0100 Subject: [PATCH 10/45] Update equipment to be more concise using pipe --- src/queries/equipment/construct-view-model.ts | 6 +- src/queries/equipment/render.ts | 254 +++++++++--------- src/types/html.ts | 8 + 3 files changed, 145 insertions(+), 123 deletions(-) diff --git a/src/queries/equipment/construct-view-model.ts b/src/queries/equipment/construct-view-model.ts index fd093e41..78bbce8c 100644 --- a/src/queries/equipment/construct-view-model.ts +++ b/src/queries/equipment/construct-view-model.ts @@ -158,8 +158,10 @@ const reduceToLatestQuizResultByMember = ( percentage: quizResult.percentage, passed: quizResult.fullMarks, timestamp: DateTime.fromSeconds(quizResult.timestampEpochS), - memberNumberProvided: quizResult.memberNumberProvided, - emailProvided: quizResult.emailProvided, + memberNumberProvided: O.fromNullable( + quizResult.memberNumberProvided + ), + emailProvided: O.fromNullable(quizResult.emailProvided), }); } return result; diff --git a/src/queries/equipment/render.ts b/src/queries/equipment/render.ts index 1469c423..91c63e2d 100644 --- a/src/queries/equipment/render.ts +++ b/src/queries/equipment/render.ts @@ -1,20 +1,38 @@ +import {pipe} from 'fp-ts/lib/function'; import {pageTemplate} from '../../templates'; import {displayDate} from '../../templates/display-date'; import {renderMemberNumber} from '../../templates/member-number'; -import {html, joinHtml, optionalSafe, sanitizeString} from '../../types/html'; -import {ViewModel} from './view-model'; +import { + blankIfEmpty, + displayIfEmpty, + html, + joinHtml, + optionalSafe, + sanitizeString, +} from '../../types/html'; +import { + QuizResultUnknownMemberViewModel, + QuizResultViewModel, + ViewModel, +} from './view-model'; import * as O from 'fp-ts/Option'; +import * as RA from 'fp-ts/ReadonlyArray'; const trainersList = (trainers: ViewModel['equipment']['trainers']) => html`

    Trainers

      - ${trainers.length > 0 - ? joinHtml( - trainers.map( + ${pipe( + trainers, + displayIfEmpty(html`

      This equipment needs trainers.

      `)(arr => + pipe( + arr, + RA.map( memberNumber => html`
    • ${renderMemberNumber(memberNumber)}
    • ` - ) + ), + joinHtml ) - : html`

      This equipment needs trainers.

      `} + ) + )}
    `; @@ -63,59 +81,74 @@ const currentlyTrainedUsersTable = (viewModel: ViewModel) => html` Member Number - ${joinHtml( - viewModel.equipment.trainedMembers.map( + ${pipe( + viewModel.equipment.trainedMembers, + RA.map( memberNumber => html` ${renderMemberNumber(memberNumber)} ` - ) + ), + joinHtml )} `; -const waitingForTrainingTable = (viewModel: ViewModel) => html` - - ${viewModel.trainingQuizResults.quizPassedNotTrained.knownMember.length > 0 - ? html` - - - - - - - - - ${joinHtml( - viewModel.trainingQuizResults.quizPassedNotTrained.knownMember.map( - quiz => html` - - - - - - - - - ` - ) - )} - ` - : html`

    No one is waiting for training

    `} -
    TimestampMember NumberScoreActions
    ${displayDate(quiz.timestamp)}${renderMemberNumber(quiz.memberNumber)} - ${quiz.score} / ${quiz.maxScore} (${quiz.percentage}%) -
    +const waitingForTrainingRow = (quiz: QuizResultViewModel) => html` + + ${sanitizeString(quiz.id)} + ${displayDate(quiz.timestamp)} + ${renderMemberNumber(quiz.memberNumber)} + ${quiz.score} / ${quiz.maxScore} (${quiz.percentage}%) + + + ${joinHtml(quiz.otherAttempts.map(sanitizeString).map(v => html`${v}`))} + + `; -const unknownMemberWaitingForTrainingTable = (viewModel: ViewModel) => html` - ${viewModel.trainingQuizResults.quizPassedNotTrained.unknownMember.length > 0 - ? html` +const waitingForTrainingTable = (viewModel: ViewModel) => + pipe( + viewModel.trainingQuizResults.quizPassedNotTrained.knownMember, + displayIfEmpty(html`

    No one is waiting for training

    `)( + passedQuizes => html` + + Quiz ID + Timestamp + Member Number + Score + Actions + Other Attempts + + ${pipe(passedQuizes, RA.map(waitingForTrainingRow), joinHtml)} + ` + ) + ); + +const passedUnknownQuizRow = ( + unknownQuiz: QuizResultUnknownMemberViewModel +) => html` + + ${sanitizeString(unknownQuiz.id)} + ${displayDate(unknownQuiz.timestamp)} + ${O.isSome(unknownQuiz.memberNumberProvided) + ? html` + ${renderMemberNumber(unknownQuiz.memberNumberProvided.value)} + ` + : html`${optionalSafe(unknownQuiz.memberNumberProvided)}`} + ${optionalSafe(unknownQuiz.emailProvided)} + + ${unknownQuiz.score} / ${unknownQuiz.maxScore} + (${unknownQuiz.percentage}%) + + +`; + +const unknownMemberWaitingForTrainingTable = (viewModel: ViewModel) => + pipe( + viewModel.trainingQuizResults.quizPassedNotTrained.unknownMember, + blankIfEmpty( + unknownQuizes => html`

    Waiting for Training - Unknown Member

    Quizes completed by members without matching email and member numbers. @@ -128,74 +161,51 @@ const unknownMemberWaitingForTrainingTable = (viewModel: ViewModel) => html` Email Provided Score - ${joinHtml( - viewModel.trainingQuizResults.quizPassedNotTrained.unknownMember.map( - unknownQuiz => html` - - ${sanitizeString(unknownQuiz.id)} - ${displayDate(unknownQuiz.timestamp)} - ${O.isSome(unknownQuiz.memberNumberProvided) - ? html` - ${renderMemberNumber( - unknownQuiz.memberNumberProvided.value - )} - ` - : html` - ${optionalSafe(unknownQuiz.memberNumberProvided)} - `} - ${optionalSafe(unknownQuiz.emailProvided)} - - ${unknownQuiz.score} / ${unknownQuiz.maxScore} - (${unknownQuiz.percentage}%) - - - ` - ) - )} + ${pipe(unknownQuizes, RA.map(passedUnknownQuizRow), joinHtml)} ` - : html``} -`; + ) + ); -const failedQuizTrainingTable = (viewModel: ViewModel) => html` - ${viewModel.trainingQuizResults.failedQuizNotTrained.knownMember.length > 0 - ? html` -

    Failed quizes

    -

    Members who haven't passed (but have attempted) the quiz

    - - - - - - - - - ${joinHtml( - viewModel.trainingQuizResults.failedQuizNotTrained.knownMember.map( - member => html` - - - - - - - - ` - ) - )} -
    TimestampMember NumberScore
    ${displayDate(member.timestamp)}${renderMemberNumber(member.memberNumber)} - ${member.score} / ${member.maxScore} (${member.percentage}%) -
    - ` - : html``} +const failedKnownQuizRow = (knownQuiz: QuizResultViewModel) => html` + + ${sanitizeString(knownQuiz.id)} + ${displayDate(knownQuiz.timestamp)} + ${renderMemberNumber(knownQuiz.memberNumber)} + + ${knownQuiz.score} / ${knownQuiz.maxScore} (${knownQuiz.percentage}%) + + + ${joinHtml( + knownQuiz.otherAttempts.map(sanitizeString).map(v => html`${v}`) + )} + + `; +const failedQuizTrainingTable = (viewModel: ViewModel) => + pipe( + viewModel.trainingQuizResults.failedQuizNotTrained.knownMember, + blankIfEmpty( + pipe( + knownQuizes => html` +

    Failed quizes

    +

    Members who haven't passed (but have attempted) the quiz

    + + + + + + + + + ${pipe(knownQuizes, RA.map(failedKnownQuizRow), joinHtml)} +
    TimestampMember NumberScore
    + ` + ) + ) + ); + const trainingQuizResults = (viewModel: ViewModel) => html`

    Training Quiz Results

    Waiting for Training

    @@ -205,11 +215,13 @@ const trainingQuizResults = (viewModel: ViewModel) => html` `; export const render = (viewModel: ViewModel) => - pageTemplate( - viewModel.equipment.name, - viewModel.user - )(html` -

    ${sanitizeString(viewModel.equipment.name)}

    - ${equipmentActions(viewModel)} ${trainersList(viewModel.equipment.trainers)} - ${currentlyTrainedUsersTable(viewModel)} ${trainingQuizResults(viewModel)} - `); + pipe( + viewModel, + (viewModel: ViewModel) => html` +

    ${sanitizeString(viewModel.equipment.name)}

    + ${equipmentActions(viewModel)} + ${trainersList(viewModel.equipment.trainers)} + ${currentlyTrainedUsersTable(viewModel)} ${trainingQuizResults(viewModel)} + `, + pageTemplate(viewModel.equipment.name, viewModel.user) + ); diff --git a/src/types/html.ts b/src/types/html.ts index 39f26569..eace03b2 100644 --- a/src/types/html.ts +++ b/src/types/html.ts @@ -41,6 +41,14 @@ export const optionalSafe = ( : data.value : safe('-'); +export const displayIfEmpty = + (ifEmpty: Html) => + (fn: (a: ReadonlyArray) => Html) => + (input: ReadonlyArray) => + input.length > 0 ? fn(input) : ifEmpty; + +export const blankIfEmpty = displayIfEmpty(html``); + interface Page { html: Html; } From b3a5f9b9e68419a11cabcf952cf86dff966c924e Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 10:27:02 +0100 Subject: [PATCH 11/45] Revert members command --- src/commands/members/edit-name-form.ts | 36 +++++++------ src/commands/members/edit-pronouns-form.ts | 36 +++++++------ .../members/sign-owner-agreement-form.ts | 50 ++++++++----------- 3 files changed, 56 insertions(+), 66 deletions(-) diff --git a/src/commands/members/edit-name-form.ts b/src/commands/members/edit-name-form.ts index c299404f..1ac84954 100644 --- a/src/commands/members/edit-name-form.ts +++ b/src/commands/members/edit-name-form.ts @@ -1,6 +1,7 @@ import {flow, pipe} from 'fp-ts/lib/function'; import * as E from 'fp-ts/Either'; import {pageTemplate} from '../../templates'; +import {html} from '../../types/html'; import {User} from '../../types'; import {Form} from '../../types/form'; import * as t from 'io-ts'; @@ -9,31 +10,28 @@ import {formatValidationErrors} from 'io-ts-reporters'; import {failureWithStatus} from '../../types/failure-with-status'; import {StatusCodes} from 'http-status-codes'; - type ViewModel = { user: User; memberNumber: number; }; -const RENDER_EDIT_NAME_FORM_TEMPLATE = Handlebars.compile(` -

    Edit name

    -
    - - - - -
    -`); - const renderForm = (viewModel: ViewModel) => - pageTemplate( - 'Edit name', - viewModel.user - )(new SafeString(RENDER_EDIT_NAME_FORM_TEMPLATE(viewModel))); + pipe( + html` +

    Edit name

    +
    + + + + +
    + `, + pageTemplate('Edit name', viewModel.user) + ); const paramsCodec = t.strict({ member: tt.NumberFromString, diff --git a/src/commands/members/edit-pronouns-form.ts b/src/commands/members/edit-pronouns-form.ts index 0090187e..4e308532 100644 --- a/src/commands/members/edit-pronouns-form.ts +++ b/src/commands/members/edit-pronouns-form.ts @@ -1,6 +1,7 @@ import {flow, pipe} from 'fp-ts/lib/function'; import * as E from 'fp-ts/Either'; import {pageTemplate} from '../../templates'; +import {html} from '../../types/html'; import {User} from '../../types'; import {Form} from '../../types/form'; import * as t from 'io-ts'; @@ -9,31 +10,28 @@ import {formatValidationErrors} from 'io-ts-reporters'; import {failureWithStatus} from '../../types/failure-with-status'; import {StatusCodes} from 'http-status-codes'; - type ViewModel = { user: User; memberNumber: number; }; -const RENDER_EDIT_PRONOUNS_TEMPLATE = Handlebars.compile(` -

    Edit pronouns

    -
    - - - - -
    -`); - const renderForm = (viewModel: ViewModel) => - pageTemplate( - 'Edit pronouns', - viewModel.user - )(new SafeString(RENDER_EDIT_PRONOUNS_TEMPLATE(viewModel))); + pipe( + html` +

    Edit pronouns

    +
    + + + + +
    + `, + pageTemplate('Edit pronouns', viewModel.user) + ); const paramsCodec = t.strict({ member: tt.NumberFromString, diff --git a/src/commands/members/sign-owner-agreement-form.ts b/src/commands/members/sign-owner-agreement-form.ts index bb2fbd36..721dd74d 100644 --- a/src/commands/members/sign-owner-agreement-form.ts +++ b/src/commands/members/sign-owner-agreement-form.ts @@ -1,45 +1,39 @@ +import {pipe} from 'fp-ts/lib/function'; import * as E from 'fp-ts/Either'; +import {pageTemplate} from '../../templates'; +import {html, safe} from '../../types/html'; import {Form} from '../../types/form'; import {User} from '../../types'; -import {pageTemplate} from '../../templates/page-template'; -import {pipe} from 'fp-ts/lib/function'; -import {html, safe} from '../../types/html'; import {ownerAgreement} from './owner-agreement'; -type ViewModel = { - user: User; - agreementGenerationTimestampIso: string; -}; - -const renderBody = (viewModel: ViewModel) => html` -

    Sign the Owner Agreement

    - ${ownerAgreement} -
    - - - -
    -`; +type ViewModel = {user: User}; const renderForm = (viewModel: ViewModel) => pipe( - viewModel, - renderBody, + html` +

    Sign the Owner Agreement

    + ${ownerAgreement} +
    + + + +
    + `, pageTemplate('Sign Owner Agreement', viewModel.user) ); const constructForm: Form['constructForm'] = () => ({user}) => - E.right({user, agreementGenerationTimestampIso: new Date().toISOString()}); + E.right({user}); export const signOwnerAgreementForm: Form = { renderForm, From 0c58ed2dfa23842c1e2960efa82cfa805a929e6f Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 10:28:35 +0100 Subject: [PATCH 12/45] Revert super-user forms to handlebars --- src/commands/super-user/declare-form.ts | 57 ++++++++++--------------- src/commands/super-user/revoke-form.ts | 48 ++++++++++----------- 2 files changed, 45 insertions(+), 60 deletions(-) diff --git a/src/commands/super-user/declare-form.ts b/src/commands/super-user/declare-form.ts index a6a097ff..e2861e72 100644 --- a/src/commands/super-user/declare-form.ts +++ b/src/commands/super-user/declare-form.ts @@ -1,46 +1,33 @@ -import * as E from 'fp-ts/Either'; import {pipe} from 'fp-ts/lib/function'; +import * as E from 'fp-ts/Either'; import {pageTemplate} from '../../templates'; -import {User, MemberDetails} from '../../types'; -import {Form} from '../../types/form'; - -import {readModels} from '../../read-models'; import {html} from '../../types/html'; -import {memberInput} from '../../templates/member-input'; +import {User} from '../../types'; +import {Form} from '../../types/form'; type ViewModel = { user: User; - members: ReadonlyArray; }; -const renderForm = (viewModel: ViewModel) => - pageTemplate( - 'Declare super user', - viewModel.user - )(html` -

    Declare super user

    -
    - - ${memberInput(viewModel.members)} - -
    - `); - -const constructForm: Form['constructForm'] = - () => - ({events, user}) => - pipe( - {user}, - E.right, - E.let('members', () => { - const memberDetails = readModels.members.getAllDetails(events); - return [...memberDetails.values()]; - }) - ); +const render = (viewModel: ViewModel) => + pipe( + html` +

    Declare super user

    +
    + + + +
    + `, + pageTemplate('Declare super user', viewModel.user) + ); export const declareForm: Form = { - renderForm, - constructForm, + renderForm: render, + constructForm: + () => + ({user}) => + E.right({user}), }; diff --git a/src/commands/super-user/revoke-form.ts b/src/commands/super-user/revoke-form.ts index 06881f7f..d09c7d95 100644 --- a/src/commands/super-user/revoke-form.ts +++ b/src/commands/super-user/revoke-form.ts @@ -3,43 +3,41 @@ import * as tt from 'io-ts-types'; import * as t from 'io-ts'; import * as E from 'fp-ts/Either'; import {pageTemplate} from '../../templates'; +import {html} from '../../types/html'; import {User} from '../../types'; import {failureWithStatus} from '../../types/failure-with-status'; import {StatusCodes} from 'http-status-codes'; import {formatValidationErrors} from 'io-ts-reporters'; import {Form} from '../../types/form'; - type ViewModel = { user: User; toBeRevoked: number; }; -const RENDER_REVOKE_SUPER_USER_TEMPLATE = Handlebars.compile(` -

    Revoke super user

    -

    - Are you sure you would like to revoke this members super-user - privileges? -

    -
    -
    Member number
    -
    {{member_number toBeRevoked}}
    -
    -
    - - -
    -`); - const renderForm = (viewModel: ViewModel) => - pageTemplate( - 'Revoke super user', - viewModel.user - )(new SafeString(RENDER_REVOKE_SUPER_USER_TEMPLATE(viewModel))); + pipe( + html` +

    Revoke super user

    +

    + Are you sure you would like to revoke this members super-user + privileges? +

    +
    +
    Member number
    +
    ${viewModel.toBeRevoked}
    +
    +
    + + +
    + `, + pageTemplate('Revoke super user', viewModel.user) + ); const paramsCodec = t.strict({ memberNumber: tt.IntFromString, From b9efc7eb9db9185435f43116b29dd0abc60ee546 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 11:27:22 +0100 Subject: [PATCH 13/45] Revert trainers back to html template --- src/commands/trainers/add-trainer-form.ts | 78 ++++++++++++------- .../trainers/mark-member-trained-form.ts | 52 ++++++------- 2 files changed, 71 insertions(+), 59 deletions(-) diff --git a/src/commands/trainers/add-trainer-form.ts b/src/commands/trainers/add-trainer-form.ts index ccbdf310..a08377e2 100644 --- a/src/commands/trainers/add-trainer-form.ts +++ b/src/commands/trainers/add-trainer-form.ts @@ -2,13 +2,15 @@ import {pipe} from 'fp-ts/lib/function'; import * as t from 'io-ts'; import * as E from 'fp-ts/Either'; import {pageTemplate} from '../../templates'; +import {html, joinHtml, sanitizeString} from '../../types/html'; import {DomainEvent, Member, User} from '../../types'; import {Form} from '../../types/form'; import {formatValidationErrors} from 'io-ts-reporters'; import {failureWithStatus} from '../../types/failure-with-status'; import {StatusCodes} from 'http-status-codes'; import {readModels} from '../../read-models'; - +import * as RA from 'fp-ts/ReadonlyArray'; +import {renderMemberNumber} from '../../templates/member-number'; type ViewModel = { user: User; @@ -17,43 +19,61 @@ type ViewModel = { equipmentName: string; }; -const RENDER_ADD_TRAINER_FORM_TEMPLATE = Handlebars.compile(` -

    Add a trainer

    - - - - - - - - - - {{#each members}} - - - +const renderForm = (viewModel: ViewModel) => + pipe( + viewModel.members, + RA.map( + member => + html` + + - - {{/each}} - -
    E-MailMember NumberAction
    {{this.emailAddress}}{{this.memberNumber}}
    ${sanitizeString(member.emailAddress)}${renderMemberNumber(member.memberNumber)}
    - +
    -`); - -const renderForm = (viewModel: ViewModel) => - pageTemplate( - 'Add Trainer', - viewModel.user - )(new SafeString(RENDER_ADD_TRAINER_FORM_TEMPLATE(viewModel))); + ` + ), + joinHtml, + tableRows => html` +

    Add a trainer

    +
    + + + + + + + + + + ${tableRows} + +
    E-MailMember Number
    + + `, + pageTemplate('Add Trainer', viewModel.user) + ); const getEquipmentId = (input: unknown) => pipe( diff --git a/src/commands/trainers/mark-member-trained-form.ts b/src/commands/trainers/mark-member-trained-form.ts index 86757eac..f7e69598 100644 --- a/src/commands/trainers/mark-member-trained-form.ts +++ b/src/commands/trainers/mark-member-trained-form.ts @@ -1,44 +1,40 @@ - import {pipe} from 'fp-ts/lib/function'; import * as E from 'fp-ts/Either'; -import {User, MemberDetails} from '../../types'; +import {html, sanitizeString} from '../../types/html'; +import {User} from '../../types'; import {Form} from '../../types/form'; import {pageTemplate} from '../../templates'; import {getEquipmentName} from '../equipment/get-equipment-name'; import {getEquipmentIdFromForm} from '../equipment/get-equipment-id-from-form'; -import {readModels} from '../../read-models'; type ViewModel = { user: User; equipmentId: string; equipmentName: string; - members: ReadonlyArray; }; +// TODO - Drop down suggestion list of users. // TODO - Warning if you try and mark a member as trained who hasn't done the quiz (for now we allow this for flexibility). -const RENDER_MARK_MEMBER_TRAINED_TEMPLATE = Handlebars.compile( - ` -

    Mark a member as trained on {{equipmentName}}

    -
    - - - {{> memberInput members }} - - -
    - ` -); - const renderForm = (viewModel: ViewModel) => - pageTemplate( - 'Member Training Complete', - viewModel.user - )(new SafeString(RENDER_MARK_MEMBER_TRAINED_TEMPLATE(viewModel))); + pipe( + html` +

    + Mark a member as trained on ${sanitizeString(viewModel.equipmentName)} +

    +
    + + + + +
    + `, + pageTemplate('Member Training Complete', viewModel.user) + ); const constructForm: Form['constructForm'] = input => @@ -49,11 +45,7 @@ const constructForm: Form['constructForm'] = E.bind('equipmentId', () => getEquipmentIdFromForm(input)), E.bind('equipmentName', ({equipmentId}) => getEquipmentName(events, equipmentId) - ), - E.let('members', () => { - const memberDetails = readModels.members.getAllDetails(events); - return [...memberDetails.values()]; - }) + ) ); export const markMemberTrainedForm: Form = { From cf694bc2704af93ac627aef044d06713629368c4 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 11:33:19 +0100 Subject: [PATCH 14/45] Revert http section back to html templates --- src/http/email-handler.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/http/email-handler.ts b/src/http/email-handler.ts index 1d5bd39a..8814d176 100644 --- a/src/http/email-handler.ts +++ b/src/http/email-handler.ts @@ -11,9 +11,9 @@ import {Actor} from '../types'; import {Request, Response} from 'express'; import * as E from 'fp-ts/Either'; import {formatValidationErrors} from 'io-ts-reporters'; +import {html} from '../types/html'; import {SendEmail} from '../commands'; import {Config} from '../configuration'; - import {isolatedPageTemplate} from '../templates/page-template'; const getActorFrom = (session: unknown, deps: Dependencies) => @@ -37,8 +37,6 @@ const getInput = (body: unknown, command: SendEmail) => TE.fromEither ); -const EMAIL_SENT_TEMPLATE = Handlebars.compile('Email sent'); - const emailPost = (conf: Config, deps: Dependencies, command: SendEmail) => async (req: Request, res: Response) => { @@ -80,17 +78,12 @@ const emailPost = () => { res .status(200) - .send( - isolatedPageTemplate('Email sent')( - new SafeString(EMAIL_SENT_TEMPLATE({})) - ) - ); + .send(isolatedPageTemplate('Email sent')(html`Email sent`)); } ) )(); }; -// ts-unused-exports:disable-next-line export const emailHandler = (conf: Config, deps: Dependencies) => (path: string, command: SendEmail) => ({ From 854c21e3bc0903bb780762143235a57c3e640018 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 11:38:27 +0100 Subject: [PATCH 15/45] all equipment -> back to html templates --- src/queries/all-equipment/render.ts | 83 +++++++++++++++-------------- 1 file changed, 42 insertions(+), 41 deletions(-) diff --git a/src/queries/all-equipment/render.ts b/src/queries/all-equipment/render.ts index d840868f..477d073a 100644 --- a/src/queries/all-equipment/render.ts +++ b/src/queries/all-equipment/render.ts @@ -1,50 +1,51 @@ +import {pipe} from 'fp-ts/lib/function'; +import {html, joinHtml, sanitizeString} from '../../types/html'; +import * as RA from 'fp-ts/ReadonlyArray'; import {ViewModel} from './view-model'; -import {pageTemplate} from '../../templates'; - -Handlebars.registerPartial( - 'render_equipment_table', - ` - - - - - - - - - {{#each equipment}} +const renderEquipment = (allEquipment: ViewModel['equipment']) => + pipe( + allEquipment, + RA.map( + equipment => html` - + - {{else}} -

    Currently no Equipment

    - {{/each}} - -
    NameArea
    {{this.name}} - {{this.areaName}} + ${sanitizeString(equipment.name)} + + ${sanitizeString(equipment.areaName)}
    - ` -); + ` + ), + RA.match( + () => html`

    Currently no Equipment

    `, + rows => html` + + + + + + + + + ${joinHtml(rows)} + +
    NameArea
    + ` + ) + ); -Handlebars.registerPartial( - 'add_area_link', - 'Add area of responsibility' -); +const addAreaCallToAction = html` + Add area of responsibility +`; -const RENDER_ALL_EQUIPMENT_TEMPLATE = Handlebars.compile( - ` +export const render = (viewModel: ViewModel) => html`

    Equipment of Makespace

    - {{#if isSuperUser}} - {{> add_area_link }} - {{/if}} - {{> render_equipment_table }} -` -); - -export const render = (viewModel: ViewModel) => - pageTemplate( - 'Equipment', - viewModel.user - )(new SafeString(RENDER_ALL_EQUIPMENT_TEMPLATE(viewModel))); + ${viewModel.isSuperUser ? addAreaCallToAction : html``} + ${renderEquipment(viewModel.equipment)} +`; From 3a673741ca70d399f3538c0d0f70b4e55fb1b974 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 11:59:39 +0100 Subject: [PATCH 16/45] Update equipment render to be nicer to read --- src/queries/equipment/render.ts | 196 +++++++++++++++++--------------- src/types/html.ts | 8 -- 2 files changed, 103 insertions(+), 101 deletions(-) diff --git a/src/queries/equipment/render.ts b/src/queries/equipment/render.ts index 91c63e2d..6f57b493 100644 --- a/src/queries/equipment/render.ts +++ b/src/queries/equipment/render.ts @@ -2,14 +2,7 @@ import {pipe} from 'fp-ts/lib/function'; import {pageTemplate} from '../../templates'; import {displayDate} from '../../templates/display-date'; import {renderMemberNumber} from '../../templates/member-number'; -import { - blankIfEmpty, - displayIfEmpty, - html, - joinHtml, - optionalSafe, - sanitizeString, -} from '../../types/html'; +import {html, joinHtml, optionalSafe, sanitizeString} from '../../types/html'; import { QuizResultUnknownMemberViewModel, QuizResultViewModel, @@ -18,23 +11,20 @@ import { import * as O from 'fp-ts/Option'; import * as RA from 'fp-ts/ReadonlyArray'; -const trainersList = (trainers: ViewModel['equipment']['trainers']) => html` -

    Trainers

    -
      - ${pipe( - trainers, - displayIfEmpty(html`

      This equipment needs trainers.

      `)(arr => - pipe( - arr, - RA.map( - memberNumber => html`
    • ${renderMemberNumber(memberNumber)}
    • ` - ), - joinHtml - ) - ) - )} -
    -`; +const trainersList = (trainers: ViewModel['equipment']['trainers']) => + pipe( + trainers, + RA.map(memberNumber => html`
  • ${renderMemberNumber(memberNumber)}
  • `), + RA.match( + () => html`

    This equipment needs trainers.

    `, + items => html` +

    Trainers

    +
      + ${joinHtml(items)} +
    + ` + ) + ); const trainerEquipmentActions = (equipment: ViewModel['equipment']) => html`
  • @@ -75,43 +65,60 @@ const equipmentActions = (viewModel: ViewModel) => html` `; -const currentlyTrainedUsersTable = (viewModel: ViewModel) => html` -

    Currently Trained Users

    - - - - - ${pipe( - viewModel.equipment.trainedMembers, - RA.map( - memberNumber => - html` - - ` - ), - joinHtml - )} -
    Member Number
    ${renderMemberNumber(memberNumber)}
    -`; +const currentlyTrainedUsersTable = (viewModel: ViewModel) => + pipe( + viewModel.equipment.trainedMembers, + RA.map(renderMemberNumber), + RA.map( + memberNumberHtml => + html` + ${memberNumberHtml} + ` + ), + joinHtml, + rows => html` +

    Currently Trained Users

    + + + + + ${rows} +
    Member Number
    + ` + ); -const waitingForTrainingRow = (quiz: QuizResultViewModel) => html` - - ${sanitizeString(quiz.id)} - ${displayDate(quiz.timestamp)} - ${renderMemberNumber(quiz.memberNumber)} - ${quiz.score} / ${quiz.maxScore} (${quiz.percentage}%) - - - ${joinHtml(quiz.otherAttempts.map(sanitizeString).map(v => html`${v}`))} - - -`; +// Hidden by default behind a visibility toggle. +const renderOtherAttempts = (otherAttempts: ReadonlyArray) => + pipe( + otherAttempts, + RA.map(sanitizeString), + RA.map(v => html`${v}`), + joinHtml + ); + +const waitingForTrainingRow = (quiz: QuizResultViewModel) => + pipe( + quiz.otherAttempts, + renderOtherAttempts, + otherAttempts => html` + + ${sanitizeString(quiz.id)} + ${displayDate(quiz.timestamp)} + ${renderMemberNumber(quiz.memberNumber)} + ${quiz.score} / ${quiz.maxScore} (${quiz.percentage}%) + + ${otherAttempts} + + ` + ); const waitingForTrainingTable = (viewModel: ViewModel) => pipe( viewModel.trainingQuizResults.quizPassedNotTrained.knownMember, - displayIfEmpty(html`

    No one is waiting for training

    `)( - passedQuizes => html` + RA.map(waitingForTrainingRow), + RA.match( + () => html`

    No one is waiting for training

    `, + rows => html` Quiz ID Timestamp @@ -120,7 +127,7 @@ const waitingForTrainingTable = (viewModel: ViewModel) => Actions Other Attempts - ${pipe(passedQuizes, RA.map(waitingForTrainingRow), joinHtml)} + ${joinHtml(rows)} ` ) ); @@ -147,8 +154,10 @@ const passedUnknownQuizRow = ( const unknownMemberWaitingForTrainingTable = (viewModel: ViewModel) => pipe( viewModel.trainingQuizResults.quizPassedNotTrained.unknownMember, - blankIfEmpty( - unknownQuizes => html` + RA.map(passedUnknownQuizRow), + RA.match( + () => html``, + rows => html`

    Waiting for Training - Unknown Member

    Quizes completed by members without matching email and member numbers. @@ -161,48 +170,49 @@ const unknownMemberWaitingForTrainingTable = (viewModel: ViewModel) => Email Provided Score - ${pipe(unknownQuizes, RA.map(passedUnknownQuizRow), joinHtml)} + ${joinHtml(rows)} ` ) ); -const failedKnownQuizRow = (knownQuiz: QuizResultViewModel) => html` - - ${sanitizeString(knownQuiz.id)} - ${displayDate(knownQuiz.timestamp)} - ${renderMemberNumber(knownQuiz.memberNumber)} - - ${knownQuiz.score} / ${knownQuiz.maxScore} (${knownQuiz.percentage}%) - - - ${joinHtml( - knownQuiz.otherAttempts.map(sanitizeString).map(v => html`${v}`) - )} - - -`; +const failedKnownQuizRow = (knownQuiz: QuizResultViewModel) => + pipe( + knownQuiz.otherAttempts, + renderOtherAttempts, + otherAttempts => html` + + ${sanitizeString(knownQuiz.id)} + ${displayDate(knownQuiz.timestamp)} + ${renderMemberNumber(knownQuiz.memberNumber)} + + ${knownQuiz.score} / ${knownQuiz.maxScore} (${knownQuiz.percentage}%) + + ${otherAttempts} + + ` + ); const failedQuizTrainingTable = (viewModel: ViewModel) => pipe( viewModel.trainingQuizResults.failedQuizNotTrained.knownMember, - blankIfEmpty( - pipe( - knownQuizes => html` -

    Failed quizes

    -

    Members who haven't passed (but have attempted) the quiz

    - - - - - - - - - ${pipe(knownQuizes, RA.map(failedKnownQuizRow), joinHtml)} -
    TimestampMember NumberScore
    - ` - ) + RA.map(failedKnownQuizRow), + RA.match( + () => html``, + rows => html` +

    Failed quizes

    +

    Members who haven't passed (but have attempted) the quiz

    + + + + + + + + + ${joinHtml(rows)} +
    TimestampMember NumberScore
    + ` ) ); diff --git a/src/types/html.ts b/src/types/html.ts index eace03b2..39f26569 100644 --- a/src/types/html.ts +++ b/src/types/html.ts @@ -41,14 +41,6 @@ export const optionalSafe = ( : data.value : safe('-'); -export const displayIfEmpty = - (ifEmpty: Html) => - (fn: (a: ReadonlyArray) => Html) => - (input: ReadonlyArray) => - input.length > 0 ? fn(input) : ifEmpty; - -export const blankIfEmpty = displayIfEmpty(html``); - interface Page { html: Html; } From 6426158a50cd5a8bd3954f29661c2a664f191d67 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 12:05:10 +0100 Subject: [PATCH 17/45] area render -> html template --- src/queries/area/render.ts | 101 ++++++++++++++++++------------------- 1 file changed, 49 insertions(+), 52 deletions(-) diff --git a/src/queries/area/render.ts b/src/queries/area/render.ts index b9e28e8b..9a7bb9cb 100644 --- a/src/queries/area/render.ts +++ b/src/queries/area/render.ts @@ -1,59 +1,56 @@ +import {pipe} from 'fp-ts/lib/function'; +import {html, joinHtml, sanitizeString} from '../../types/html'; import {ViewModel} from './view-model'; -import {pageTemplate} from '../../templates'; +import * as RA from 'fp-ts/ReadonlyArray'; +const renderOwners = (owners: ViewModel['area']['owners']) => + pipe( + owners, + RA.map(owner => html`
  • ${owner}
  • `), + joinHtml, + items => + html`
      + ${items} +
    ` + ); -Handlebars.registerPartial( - 'owners_list', - ` -
      - {{#each area.owners}} -
    • {{this}}
    • - {{/each}} -
    - ` -); +const addEquipmentCallToAction = (areaId: string) => html` + Add piece of red equipment +`; -Handlebars.registerPartial( - 'add_red_equipment_link', - ` - Add piece of red equipment - ` -); +const addOwnerCallToAction = (areaId: string) => html` + Add owner +`; -Handlebars.registerPartial( - 'add_owner_link', - ` - Add owner - ` -); - -Handlebars.registerPartial( - 'equipment_list', - ` - - ` -); - -const RENDER_AREA_TEMPLATE = Handlebars.compile(` -

    {{area.name}}

    - {{#if isSuperUser}} - {{> add_red_equipment_link }} - {{/if}} -

    Owners

    - {{#if isSuperUser}} - {{> add_owner_link }} - {{/if}} - {{> owners_list }} -

    Equipment

    - {{> equipment_list }} -`); +const renderEquipment = (allEquipment: ViewModel['equipment']) => + pipe( + allEquipment, + RA.map( + equipment => html` +
  • + ${sanitizeString(equipment.name)} +
  • + ` + ), + joinHtml, + items => html` +
      + ${items} +
    + ` + ); export const render = (viewModel: ViewModel) => - pageTemplate( - viewModel.area.name, - viewModel.user - )(new SafeString(RENDER_AREA_TEMPLATE(viewModel))); + html`

    ${sanitizeString(viewModel.area.name)}

    + ${viewModel.isSuperUser + ? addEquipmentCallToAction(viewModel.area.id) + : html``} +

    Owners

    + ${viewModel.isSuperUser ? addOwnerCallToAction(viewModel.area.id) : html``} + ${renderOwners(viewModel.area.owners)} +

    Equipment

    + ${renderEquipment(viewModel.equipment)} `; From 577e461d4a73347b90cb28b82d25259e6388f34f Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 12:09:39 +0100 Subject: [PATCH 18/45] render areas -> html template --- src/queries/areas/render.ts | 92 ++++++++++++++++++------------------- src/types/html.ts | 4 ++ 2 files changed, 50 insertions(+), 46 deletions(-) diff --git a/src/queries/areas/render.ts b/src/queries/areas/render.ts index 224aa7e9..322beceb 100644 --- a/src/queries/areas/render.ts +++ b/src/queries/areas/render.ts @@ -1,53 +1,53 @@ +import {pipe} from 'fp-ts/lib/function'; +import {commaHtml, html, joinHtml, sanitizeString} from '../../types/html'; +import * as RA from 'fp-ts/ReadonlyArray'; import {ViewModel} from './view-model'; -import {pageTemplate} from '../../templates'; - -Handlebars.registerPartial( - 'areas_table', - ` - {{#if areas}} - - +const renderAreas = (areas: ViewModel['areas']) => + pipe( + areas, + RA.map( + area => html` - - - + + + - - - {{#each areas}} - - - - - - {{/each}} - -
    NameOwnersActions + ${sanitizeString(area.name)} + ${commaHtml(area.owners)} + Add owner +
    {{this.name}} - {{#each owners}} - {{member_number this}} - {{/each}} - Add owner
    - {{else}} -

    Currently no Areas

    - {{/if}} - ` -); + ` + ), + RA.match( + () => html`

    Currently no Areas

    `, + rows => html` + + + + + + + + + + ${joinHtml(rows)} + +
    NameOwnersActions
    + ` + ) + ); -Handlebars.registerPartial( - 'add_area_link', - 'Add area of responsibility' -); +const addAreaCallToAction = html` + Add area of responsibility +`; -const RENDER_AREAS_TEMPLATE = Handlebars.compile(` +export const render = (viewModel: ViewModel) => html`

    Areas of Makespace

    - {{#if isSuperUser }} - {{> add_area_link}} - {{/if}} - {{> areas_table}} -`); -export const render = (viewModel: ViewModel) => - pageTemplate( - 'Areas', - viewModel.user - )(new SafeString(RENDER_AREAS_TEMPLATE(viewModel))); + ${viewModel.isSuperUser ? addAreaCallToAction : html``} + ${renderAreas(viewModel.areas)} +`; diff --git a/src/types/html.ts b/src/types/html.ts index 39f26569..0be996c2 100644 --- a/src/types/html.ts +++ b/src/types/html.ts @@ -14,6 +14,10 @@ export const sanitizeString = (input: string): SanitizedString => export const joinHtml = (input: ReadonlyArray) => input.join('\n') as Html; +export const commaHtml = ( + input: ReadonlyArray +) => input.join(', ') as Html; + export const safe = (input: string): Safe => input as Safe; export const html = ( From 30b56c48f7cc6e0cb99fb6822fd422d82ff8ba0c Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 12:14:39 +0100 Subject: [PATCH 19/45] Try allowing UUID in html templates --- src/queries/areas/render.ts | 8 ++------ src/read-models/areas/area.ts | 4 +++- src/types/html.ts | 3 ++- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/queries/areas/render.ts b/src/queries/areas/render.ts index 322beceb..72074b46 100644 --- a/src/queries/areas/render.ts +++ b/src/queries/areas/render.ts @@ -10,15 +10,11 @@ const renderAreas = (areas: ViewModel['areas']) => area => html` - ${sanitizeString(area.name)} + ${sanitizeString(area.name)} ${commaHtml(area.owners)} - Add owner + Add owner ` diff --git a/src/read-models/areas/area.ts b/src/read-models/areas/area.ts index b5381890..05a27a1d 100644 --- a/src/read-models/areas/area.ts +++ b/src/read-models/areas/area.ts @@ -1,5 +1,7 @@ +import {UUID} from 'io-ts-types'; + export type Area = { - id: string; + id: UUID; name: string; owners: number[]; }; diff --git a/src/types/html.ts b/src/types/html.ts index 0be996c2..94e22fbb 100644 --- a/src/types/html.ts +++ b/src/types/html.ts @@ -1,5 +1,6 @@ import * as Sum from '@unsplash/sum-types'; import * as O from 'fp-ts/Option'; +import {UUID} from 'io-ts-types'; import sanitize from 'sanitize-html'; export type Html = string & {readonly Html: unique symbol}; @@ -22,7 +23,7 @@ export const safe = (input: string): Safe => input as Safe; export const html = ( literals: TemplateStringsArray, - ...substitutions: ReadonlyArray + ...substitutions: ReadonlyArray ): Html => { if (literals.length === 1 && substitutions.length === 0) { return literals[0] as Html; From f3cc92fa1048c39109db08f7a1193d579b8c81f1 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 12:22:54 +0100 Subject: [PATCH 20/45] Use UUID rather than string so that we know it doesn't need sanitised --- src/commands/area/create-form.ts | 5 +++-- src/commands/equipment/add-form.ts | 15 ++++++--------- .../equipment/get-equipment-id-from-form.ts | 3 ++- .../equipment/register-training-sheet-form.ts | 5 +++-- src/commands/trainers/add-trainer-form.ts | 7 ++++--- src/commands/trainers/mark-member-trained-form.ts | 5 +++-- src/queries/all-equipment/render.ts | 4 ++-- src/queries/all-equipment/view-model.ts | 5 +++-- src/queries/area/render.ts | 13 ++++++------- src/queries/area/view-model.ts | 3 ++- 10 files changed, 34 insertions(+), 31 deletions(-) diff --git a/src/commands/area/create-form.ts b/src/commands/area/create-form.ts index fd4d0833..88efda67 100644 --- a/src/commands/area/create-form.ts +++ b/src/commands/area/create-form.ts @@ -3,8 +3,9 @@ import {pageTemplate} from '../../templates'; import {User} from '../../types'; import {Form} from '../../types/form'; import {pipe} from 'fp-ts/lib/function'; -import {html, safe} from '../../types/html'; +import {html} from '../../types/html'; import {v4} from 'uuid'; +import {UUID} from 'io-ts-types'; type ViewModel = { user: User; @@ -18,7 +19,7 @@ const renderForm = (viewModel: ViewModel) =>
    - +
    `, diff --git a/src/commands/equipment/add-form.ts b/src/commands/equipment/add-form.ts index dd457412..15ba48bc 100644 --- a/src/commands/equipment/add-form.ts +++ b/src/commands/equipment/add-form.ts @@ -2,7 +2,7 @@ import {pipe} from 'fp-ts/lib/function'; import * as t from 'io-ts'; import * as E from 'fp-ts/Either'; import {pageTemplate} from '../../templates'; -import {html, safe, sanitizeString} from '../../types/html'; +import {html, sanitizeString} from '../../types/html'; import {DomainEvent, User} from '../../types'; import {v4} from 'uuid'; import {Form} from '../../types/form'; @@ -10,10 +10,11 @@ import {formatValidationErrors} from 'io-ts-reporters'; import {failureWithStatus} from '../../types/failure-with-status'; import {StatusCodes} from 'http-status-codes'; import {readModels} from '../../read-models'; +import {UUID} from 'io-ts-types'; type ViewModel = { user: User; - areaId: string; + areaId: UUID; areaName: string; }; @@ -24,12 +25,8 @@ const renderForm = (viewModel: ViewModel) =>
    - - + +
    `, @@ -39,7 +36,7 @@ const renderForm = (viewModel: ViewModel) => const getAreaId = (input: unknown) => pipe( input, - t.strict({area: t.string}).decode, + t.strict({area: UUID}).decode, E.mapLeft(formatValidationErrors), E.mapLeft(failureWithStatus('Invalid parameters', StatusCodes.BAD_REQUEST)), E.map(({area}) => area) diff --git a/src/commands/equipment/get-equipment-id-from-form.ts b/src/commands/equipment/get-equipment-id-from-form.ts index 0a8bd53b..b5336f6e 100644 --- a/src/commands/equipment/get-equipment-id-from-form.ts +++ b/src/commands/equipment/get-equipment-id-from-form.ts @@ -1,12 +1,13 @@ import {pipe} from 'fp-ts/lib/function'; import * as t from 'io-ts'; +import * as tt from 'io-ts-types'; import * as E from 'fp-ts/Either'; import {formatValidationErrors} from 'io-ts-reporters'; import {failureWithStatus} from '../../types/failure-with-status'; import {StatusCodes} from 'http-status-codes'; const getEquipmentIdCodec = t.strict({ - equipmentId: t.string, + equipmentId: tt.UUID, }); export const getEquipmentIdFromForm = (input: unknown) => diff --git a/src/commands/equipment/register-training-sheet-form.ts b/src/commands/equipment/register-training-sheet-form.ts index dfd98d38..953cf00a 100644 --- a/src/commands/equipment/register-training-sheet-form.ts +++ b/src/commands/equipment/register-training-sheet-form.ts @@ -6,10 +6,11 @@ import {Form} from '../../types/form'; import {pageTemplate} from '../../templates'; import {getEquipmentName} from './get-equipment-name'; import {getEquipmentIdFromForm} from './get-equipment-id-from-form'; +import {UUID} from 'io-ts-types'; type ViewModel = { user: User; - equipmentId: string; + equipmentId: UUID; equipmentName: string; }; @@ -25,7 +26,7 @@ const renderForm = (viewModel: ViewModel) => diff --git a/src/commands/trainers/add-trainer-form.ts b/src/commands/trainers/add-trainer-form.ts index a08377e2..f4944ee3 100644 --- a/src/commands/trainers/add-trainer-form.ts +++ b/src/commands/trainers/add-trainer-form.ts @@ -11,11 +11,12 @@ import {StatusCodes} from 'http-status-codes'; import {readModels} from '../../read-models'; import * as RA from 'fp-ts/ReadonlyArray'; import {renderMemberNumber} from '../../templates/member-number'; +import {UUID} from 'io-ts-types'; type ViewModel = { user: User; members: ReadonlyArray; - equipmentId: string; + equipmentId: UUID; equipmentName: string; }; @@ -37,7 +38,7 @@ const renderForm = (viewModel: ViewModel) => @@ -78,7 +79,7 @@ const renderForm = (viewModel: ViewModel) => const getEquipmentId = (input: unknown) => pipe( input, - t.strict({equipment: t.string}).decode, + t.strict({equipment: UUID}).decode, E.mapLeft(formatValidationErrors), E.mapLeft(failureWithStatus('Invalid parameters', StatusCodes.BAD_REQUEST)), E.map(({equipment}) => equipment) diff --git a/src/commands/trainers/mark-member-trained-form.ts b/src/commands/trainers/mark-member-trained-form.ts index f7e69598..f63373ec 100644 --- a/src/commands/trainers/mark-member-trained-form.ts +++ b/src/commands/trainers/mark-member-trained-form.ts @@ -6,10 +6,11 @@ import {Form} from '../../types/form'; import {pageTemplate} from '../../templates'; import {getEquipmentName} from '../equipment/get-equipment-name'; import {getEquipmentIdFromForm} from '../equipment/get-equipment-id-from-form'; +import {UUID} from 'io-ts-types'; type ViewModel = { user: User; - equipmentId: string; + equipmentId: UUID; equipmentName: string; }; @@ -28,7 +29,7 @@ const renderForm = (viewModel: ViewModel) => diff --git a/src/queries/all-equipment/render.ts b/src/queries/all-equipment/render.ts index 477d073a..247052af 100644 --- a/src/queries/all-equipment/render.ts +++ b/src/queries/all-equipment/render.ts @@ -10,12 +10,12 @@ const renderEquipment = (allEquipment: ViewModel['equipment']) => equipment => html` - ${sanitizeString(equipment.name)} - ${sanitizeString(equipment.areaName)} diff --git a/src/queries/all-equipment/view-model.ts b/src/queries/all-equipment/view-model.ts index 65c2af69..764d8673 100644 --- a/src/queries/all-equipment/view-model.ts +++ b/src/queries/all-equipment/view-model.ts @@ -1,9 +1,10 @@ +import {UUID} from 'io-ts-types'; import {User} from '../../types'; type Equipment = { name: string; - id: string; - areaId: string; + id: UUID; + areaId: UUID; areaName: string; }; diff --git a/src/queries/area/render.ts b/src/queries/area/render.ts index 9a7bb9cb..1175c13f 100644 --- a/src/queries/area/render.ts +++ b/src/queries/area/render.ts @@ -2,6 +2,7 @@ import {pipe} from 'fp-ts/lib/function'; import {html, joinHtml, sanitizeString} from '../../types/html'; import {ViewModel} from './view-model'; import * as RA from 'fp-ts/ReadonlyArray'; +import {UUID} from 'io-ts-types'; const renderOwners = (owners: ViewModel['area']['owners']) => pipe( @@ -14,14 +15,12 @@ const renderOwners = (owners: ViewModel['area']['owners']) => ` ); -const addEquipmentCallToAction = (areaId: string) => html` - Add piece of red equipment +const addEquipmentCallToAction = (areaId: UUID) => html` + Add piece of red equipment `; -const addOwnerCallToAction = (areaId: string) => html` - Add owner +const addOwnerCallToAction = (areaId: UUID) => html` + Add owner `; const renderEquipment = (allEquipment: ViewModel['equipment']) => @@ -30,7 +29,7 @@ const renderEquipment = (allEquipment: ViewModel['equipment']) => RA.map( equipment => html`
  • - ${sanitizeString(equipment.name)}
  • diff --git a/src/queries/area/view-model.ts b/src/queries/area/view-model.ts index fe4bf418..86db9e19 100644 --- a/src/queries/area/view-model.ts +++ b/src/queries/area/view-model.ts @@ -1,3 +1,4 @@ +import {UUID} from 'io-ts-types'; import {Area} from '../../read-models/areas'; import {User} from '../../types'; @@ -6,7 +7,7 @@ export type ViewModel = { user: User; isSuperUser: boolean; equipment: ReadonlyArray<{ - id: string; + id: UUID; name: string; }>; }; From bc9e6ecfe90fe7f8dcf125d10f1ea5f7c7dbbb18 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 12:28:21 +0100 Subject: [PATCH 21/45] Remove unneeded sanitise --- src/queries/equipment/render.ts | 17 +++++------------ src/queries/equipment/view-model.ts | 5 +++-- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/queries/equipment/render.ts b/src/queries/equipment/render.ts index 6f57b493..82aa3a70 100644 --- a/src/queries/equipment/render.ts +++ b/src/queries/equipment/render.ts @@ -10,6 +10,7 @@ import { } from './view-model'; import * as O from 'fp-ts/Option'; import * as RA from 'fp-ts/ReadonlyArray'; +import {UUID} from 'io-ts-types'; const trainersList = (trainers: ViewModel['equipment']['trainers']) => pipe( @@ -28,10 +29,7 @@ const trainersList = (trainers: ViewModel['equipment']['trainers']) => const trainerEquipmentActions = (equipment: ViewModel['equipment']) => html`
  • - Mark member as trained
  • @@ -39,16 +37,12 @@ const trainerEquipmentActions = (equipment: ViewModel['equipment']) => html` const ownerEquipmentActions = (equipment: ViewModel['equipment']) => html`
  • - + Add a trainer
  • - + Register training sheet
  • @@ -88,10 +82,9 @@ const currentlyTrainedUsersTable = (viewModel: ViewModel) => ); // Hidden by default behind a visibility toggle. -const renderOtherAttempts = (otherAttempts: ReadonlyArray) => +const renderOtherAttempts = (otherAttempts: ReadonlyArray) => pipe( otherAttempts, - RA.map(sanitizeString), RA.map(v => html`${v}`), joinHtml ); diff --git a/src/queries/equipment/view-model.ts b/src/queries/equipment/view-model.ts index eebb3622..bc6b5999 100644 --- a/src/queries/equipment/view-model.ts +++ b/src/queries/equipment/view-model.ts @@ -1,8 +1,9 @@ import {DateTime} from 'luxon'; import {User} from '../../types'; import * as O from 'fp-ts/Option'; +import {UUID} from 'io-ts-types'; -type QuizID = string; +type QuizID = UUID; export type QuizResultViewModel = { id: QuizID; @@ -35,7 +36,7 @@ export type ViewModel = { isSuperUserOrTrainerOfArea: boolean; equipment: { name: string; - id: string; + id: UUID; trainers: ReadonlyArray; trainedMembers: ReadonlyArray; }; From e51b66dae26ba3fb7181e46817cb1b49c2b4d64d Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 12:32:49 +0100 Subject: [PATCH 22/45] failed imports -> back to html template --- src/queries/failed-imports/render.ts | 42 ++++++++++++++-------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/queries/failed-imports/render.ts b/src/queries/failed-imports/render.ts index 70088293..0a26f8a1 100644 --- a/src/queries/failed-imports/render.ts +++ b/src/queries/failed-imports/render.ts @@ -1,29 +1,29 @@ +import {pipe} from 'fp-ts/lib/function'; +import {html, joinHtml, sanitizeString} from '../../types/html'; import {ViewModel} from './view-model'; -import {pageTemplate} from '../../templates'; +import * as RA from 'fp-ts/ReadonlyArray'; +const renderFailedLinkings = (failedImports: ViewModel['failedImports']) => + pipe( + failedImports, + RA.map( + item => + html`
  • + ${item.memberNumber} -- ${sanitizeString(item.email)} +
  • ` + ), + joinHtml, + joined => + html`
      + ${joined} +
    ` + ); -Handlebars.registerPartial( - 'failed_imports_list', - ` -
      - {{#each failedImports}} -
    • {{member_number this.memberNumber}} -- {{this.emailAddress}}
    • - {{/each}} -
    - ` -); - -const RENDER_FAILED_IMPORTS_TEMPLATE = Handlebars.compile(` +export const render = (viewModel: ViewModel) => html`

    Failed member imports

    During import from the legacy database the following members could not be imported because the email address is already used by another member.

    - {{> failed_imports_list }} -`); - -export const render = (viewModel: ViewModel) => - pageTemplate( - 'Failed member imports', - viewModel.user - )(new SafeString(RENDER_FAILED_IMPORTS_TEMPLATE(viewModel))); + ${renderFailedLinkings(viewModel.failedImports)} +`; From c1688492ee89f232b521f8f264e853ab2eb873ff Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 12:35:31 +0100 Subject: [PATCH 23/45] Allow empty string in html templates unsanitised --- src/queries/all-equipment/render.ts | 2 +- src/queries/area/render.ts | 6 ++---- src/queries/areas/render.ts | 2 +- src/queries/equipment/render.ts | 4 ++-- src/types/html.ts | 4 +++- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/queries/all-equipment/render.ts b/src/queries/all-equipment/render.ts index 247052af..f6a25d87 100644 --- a/src/queries/all-equipment/render.ts +++ b/src/queries/all-equipment/render.ts @@ -46,6 +46,6 @@ const addAreaCallToAction = html` export const render = (viewModel: ViewModel) => html`

    Equipment of Makespace

    - ${viewModel.isSuperUser ? addAreaCallToAction : html``} + ${viewModel.isSuperUser ? addAreaCallToAction : ''} ${renderEquipment(viewModel.equipment)} `; diff --git a/src/queries/area/render.ts b/src/queries/area/render.ts index 1175c13f..ed83bc5e 100644 --- a/src/queries/area/render.ts +++ b/src/queries/area/render.ts @@ -45,11 +45,9 @@ const renderEquipment = (allEquipment: ViewModel['equipment']) => export const render = (viewModel: ViewModel) => html`

    ${sanitizeString(viewModel.area.name)}

    - ${viewModel.isSuperUser - ? addEquipmentCallToAction(viewModel.area.id) - : html``} + ${viewModel.isSuperUser ? addEquipmentCallToAction(viewModel.area.id) : ''}

    Owners

    - ${viewModel.isSuperUser ? addOwnerCallToAction(viewModel.area.id) : html``} + ${viewModel.isSuperUser ? addOwnerCallToAction(viewModel.area.id) : ''} ${renderOwners(viewModel.area.owners)}

    Equipment

    ${renderEquipment(viewModel.equipment)} `; diff --git a/src/queries/areas/render.ts b/src/queries/areas/render.ts index 72074b46..80ea2574 100644 --- a/src/queries/areas/render.ts +++ b/src/queries/areas/render.ts @@ -44,6 +44,6 @@ const addAreaCallToAction = html` export const render = (viewModel: ViewModel) => html`

    Areas of Makespace

    - ${viewModel.isSuperUser ? addAreaCallToAction : html``} + ${viewModel.isSuperUser ? addAreaCallToAction : ''} ${renderAreas(viewModel.areas)} `; diff --git a/src/queries/equipment/render.ts b/src/queries/equipment/render.ts index 82aa3a70..4d25a336 100644 --- a/src/queries/equipment/render.ts +++ b/src/queries/equipment/render.ts @@ -52,10 +52,10 @@ const equipmentActions = (viewModel: ViewModel) => html`
      ${viewModel.isSuperUserOrOwnerOfArea ? ownerEquipmentActions(viewModel.equipment) - : html``} + : ''} ${viewModel.isSuperUserOrTrainerOfArea ? trainerEquipmentActions(viewModel.equipment) - : html``} + : ''}
    `; diff --git a/src/types/html.ts b/src/types/html.ts index 94e22fbb..28c99955 100644 --- a/src/types/html.ts +++ b/src/types/html.ts @@ -23,7 +23,9 @@ export const safe = (input: string): Safe => input as Safe; export const html = ( literals: TemplateStringsArray, - ...substitutions: ReadonlyArray + ...substitutions: ReadonlyArray< + Html | number | SanitizedString | Safe | UUID | '' + > ): Html => { if (literals.length === 1 && substitutions.length === 0) { return literals[0] as Html; From 027a3eb805aac1104b92585e8318858aa67d9e04 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 12:35:51 +0100 Subject: [PATCH 24/45] Landing page back to html template --- src/queries/landing/render.ts | 49 ++++++++++++----------------------- 1 file changed, 17 insertions(+), 32 deletions(-) diff --git a/src/queries/landing/render.ts b/src/queries/landing/render.ts index eab46d15..bbd8c110 100644 --- a/src/queries/landing/render.ts +++ b/src/queries/landing/render.ts @@ -1,22 +1,17 @@ +import {pipe} from 'fp-ts/lib/function'; +import {html, sanitizeString} from '../../types/html'; import {ViewModel} from './view-model'; -import {pageTemplate} from '../../templates'; - -Handlebars.registerPartial( - 'landing_page_member_details', - ` +const renderMemberDetails = (user: ViewModel['user']) => html`
    Email
    -
    {{user.emailAddress}}
    +
    ${sanitizeString(user.emailAddress)}
    Member Number
    -
    {{member_number user.memberNumber}}
    +
    ${user.memberNumber}
    -` -); +`; -Handlebars.registerPartial( - 'super_user_nav', - ` +const superUserNav = html`

    Admin

    You have super-user privileges. You can:

    -` -); +`; -const LANDING_PAGE_TEMPLATE = Handlebars.compile(` -

    Makespace Member Dashboard

    -

    Your Details

    - {{> landing_page_member_details}} - {{#if isSuperUser}} - {{> super_user_nav}} - {{/if}} - -`); export const render = (viewModel: ViewModel) => - pageTemplate( - 'Dashboard', - viewModel.user - )(new SafeString(LANDING_PAGE_TEMPLATE(viewModel))); + pipe( + html` +

    Makespace Member Dashboard

    +

    Your Details

    + ${renderMemberDetails(viewModel.user)} + ${viewModel.isSuperUser ? superUserNav : ''} + + ` + ); From 7f7ab541926958d8d39d0e00dceaee5b83d7d6d7 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 12:42:40 +0100 Subject: [PATCH 25/45] Render events back to html template --- src/queries/log/render.ts | 72 ++++++++++++++++++++--------------- src/templates/display-date.ts | 17 +++------ src/types/html.ts | 5 ++- 3 files changed, 49 insertions(+), 45 deletions(-) diff --git a/src/queries/log/render.ts b/src/queries/log/render.ts index 4bff6675..c57c2978 100644 --- a/src/queries/log/render.ts +++ b/src/queries/log/render.ts @@ -1,47 +1,57 @@ import {pipe} from 'fp-ts/lib/function'; +import * as RA from 'fp-ts/ReadonlyArray'; +import {html, joinHtml, sanitizeString} from '../../types/html'; import {ViewModel} from './view-model'; import {Actor} from '../../types/actor'; import {DomainEvent} from '../../types'; import {inspect} from 'node:util'; -import {pageTemplate} from '../../templates'; +import {displayDate} from '../../templates/display-date'; - -Handlebars.registerHelper('render_actor', (actor: Actor) => { +const renderActor = (actor: Actor) => { switch (actor.tag) { case 'system': - return 'System'; + return html`System`; case 'token': - return 'Admin via API'; + return html`Admin via API`; case 'user': - return actor.user.emailAddress; + return sanitizeString(actor.user.emailAddress); } -}); +}; -Handlebars.registerHelper('render_event_payload', (event: DomainEvent) => +const renderPayload = (event: DomainEvent) => // eslint-disable-next-line @typescript-eslint/no-unused-vars, unused-imports/no-unused-vars - pipe(event, ({type, actor, recordedAt, ...payload}) => { - return Object.entries(payload) - .map(([key, value]) => `${key}: ${inspect(value)}`) - .join(', '); - }) -); + pipe(event, ({type, actor, recordedAt, ...payload}) => + pipe( + payload, + Object.entries, + RA.map(([key, value]) => `${key}: ${inspect(value)}`), + RA.map(sanitizeString), + joinHtml + ) + ); + +const renderEntry = (event: ViewModel['events'][number]) => html` +
  • + ${sanitizeString(event.type)} by ${renderActor(event.actor)} at + ${displayDate(event.recordedAt)}
    + ${renderPayload(event)} +
  • +`; -const RENDER_LOG_TEMPLATE = Handlebars.compile(` +const renderLog = (log: ViewModel['events']) => + pipe( + log, + RA.map(renderEntry), + joinHtml, + items => html` +
      + ${items} +
    + ` + ); + +export const render = (viewModel: ViewModel) => html`

    Event log

    Most recent at top

    -
      - {{#each events}} -
    • - {{this.type}} by {{render_actor this.actor}} at - {{display_date this.recordedAt}}
      - {{render_event_payload this}} -
    • - {{/each}} -
    -`); - -export const render = (viewModel: ViewModel) => - pageTemplate( - 'Event Log', - viewModel.user - )(new SafeString(RENDER_LOG_TEMPLATE(viewModel))); + ${renderLog(viewModel.events)} +`; diff --git a/src/templates/display-date.ts b/src/templates/display-date.ts index 8a4802b1..56bec806 100644 --- a/src/templates/display-date.ts +++ b/src/templates/display-date.ts @@ -1,15 +1,8 @@ import {DateTime} from 'luxon'; import {Safe, safe} from '../types/html'; -export const displayDate = (date: DateTime | number): Safe => { - // TODO Do this properly. https://github.com/Makespace/members-app/issues/40 - switch (typeof date) { - case 'number': - return safe(DateTime.fromMillis(date).toLocaleString()); - default: - if (date instanceof DateTime) { - return safe(date.toLocaleString()); - } - return safe('Unknown Date'); // Placeholder. - } -}; +// TODO Do this properly. https://github.com/Makespace/members-app/issues/40 +export const displayDate = (date: DateTime | Date | number): Safe => + typeof date === 'number' + ? safe(DateTime.fromMillis(date).toLocaleString()) + : safe(date.toLocaleString()); diff --git a/src/types/html.ts b/src/types/html.ts index 28c99955..2972cd1b 100644 --- a/src/types/html.ts +++ b/src/types/html.ts @@ -12,8 +12,9 @@ export type Safe = string & {readonly Safe: unique symbol}; export const sanitizeString = (input: string): SanitizedString => sanitize(input) as SanitizedString; -export const joinHtml = (input: ReadonlyArray) => - input.join('\n') as Html; +export const joinHtml = ( + input: ReadonlyArray +) => input.join('\n') as Html; export const commaHtml = ( input: ReadonlyArray From cfbcdea080b4356200c363f9dc4232ef775abdec Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 12:47:01 +0100 Subject: [PATCH 26/45] member page -> html template --- src/queries/member/render.ts | 70 ++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/src/queries/member/render.ts b/src/queries/member/render.ts index 6cc31e11..73f920b4 100644 --- a/src/queries/member/render.ts +++ b/src/queries/member/render.ts @@ -1,13 +1,33 @@ - -import {pageTemplate} from '../../templates'; +import {getGravatarProfile, getGravatarThumbnail} from '../../templates/avatar'; +import {Html, html, optionalSafe, sanitizeString} from '../../types/html'; import {ViewModel} from './view-model'; -const RENDER_TEMPLATE = Handlebars.compile( - ` - {{#if isSelf}} -

    This is your profile!

    - {{/if}} -
    {{avatar_large member.emailAddress member.memberNumber}}
    +const ownPageBanner = html`

    This is your profile!

    `; + +const editName = (viewModel: ViewModel) => + html`Edit`; + +const editPronouns = (viewModel: ViewModel) => + html`Edit`; + +const editAvatar = () => + html`Edit via Gravatar`; + +const ifSelf = (viewModel: ViewModel, fragment: Html) => + viewModel.isSelf ? fragment : ''; + +export const render = (viewModel: ViewModel) => html` + ${ifSelf(viewModel, ownPageBanner)} +
    + ${getGravatarProfile( + viewModel.member.emailAddress, + viewModel.member.memberNumber + )} +
    - + - +
    Details @@ -15,46 +35,36 @@ const RENDER_TEMPLATE = Handlebars.compile(
    Member number {{member_number member.memberNumber}}${viewModel.member.memberNumber}
    Email {{member.emailAddress}}${sanitizeString(viewModel.member.emailAddress)}
    Name - {{optional_detail member.name}} - {{#if isSelf}} - Edit - {{/if}} + ${optionalSafe(viewModel.member.name)} + ${ifSelf(viewModel, editName(viewModel))}
    Pronouns - {{optional_detail member.pronouns}} - {{#if isSelf}} - Edit - {{/if}} + ${optionalSafe(viewModel.member.pronouns)} + ${ifSelf(viewModel, editPronouns(viewModel))}
    Avatar - {{avatar_thumbnail member.emailAddress member.memberNumber}} - {{#if isSelf}} - Edit via Gravatar - {{/if}} + ${getGravatarThumbnail( + viewModel.member.emailAddress, + viewModel.member.memberNumber + )} + ${ifSelf(viewModel, editAvatar())}
    -` -); - -export const render = (viewModel: ViewModel) => - pageTemplate( - 'Member', - viewModel.user - )(new SafeString(RENDER_TEMPLATE(viewModel))); +`; From 322f7433b4f0ddda0c37b256b5cf59590b844c23 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 12:49:50 +0100 Subject: [PATCH 27/45] members -> html template --- src/queries/members/render.ts | 90 +++++++++++++++++------------------ 1 file changed, 44 insertions(+), 46 deletions(-) diff --git a/src/queries/members/render.ts b/src/queries/members/render.ts index 0b850699..e5ca309f 100644 --- a/src/queries/members/render.ts +++ b/src/queries/members/render.ts @@ -1,54 +1,52 @@ -import {pageTemplate} from '../../templates'; +import {pipe} from 'fp-ts/lib/function'; +import {html, joinHtml, optionalSafe, sanitizeString} from '../../types/html'; +import * as RA from 'fp-ts/ReadonlyArray'; import {ViewModel} from './view-model'; +import {getGravatarThumbnail} from '../../templates/avatar'; +import {renderMemberNumber} from '../../templates/member-number'; - -Handlebars.registerPartial( - 'render_members', - ` - - - - - - - - - - - - {{#each members}} +const renderMembers = (viewModel: ViewModel) => + pipe( + viewModel.members, + RA.map( + member => html` - + + + + - - - {{#if @root.viewerIsSuperUser}} - - {{else}} - - {{/if}} - {{/each}} - -
    Member numberNamePronounsEmail
    {{avatar_thumbnail this.emailAddress this.memberNumber}} - {{member_number this.memberNumber}} + ${getGravatarThumbnail(member.emailAddress, member.memberNumber)} + ${renderMemberNumber(member.memberNumber)}${optionalSafe(member.name)}${optionalSafe(member.pronouns)} + ${viewModel.viewerIsSuperUser + ? sanitizeString(member.emailAddress) + : html`*****`} {{optional_detail this.name}}{{optional_detail this.pronouns}}{{this.emailAddress}}'*****'
    - ` -); + ` + ), + RA.match( + () => html`

    Currently no members

    `, + rows => html` + + + + + + + + + + + + ${joinHtml(rows)} + +
    Member numberNamePronounsEmail
    + ` + ) + ); -const RENDER_MEMBERS_TEMPLATE = Handlebars.compile( - ` +export const render = (viewModel: ViewModel) => html`

    Members of Makespace

    - {{#if members}} - {{> render_members}} - {{else}} -

    Currently no members

    - {{/if}} -` -); - -export const render = (viewModel: ViewModel) => - pageTemplate( - 'Members', - viewModel.user - )(new SafeString(RENDER_MEMBERS_TEMPLATE(viewModel))); + ${renderMembers(viewModel)} +`; From bd7860702942733662c41f7efe21acacab60e974 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 12:51:09 +0100 Subject: [PATCH 28/45] super users back to html template --- src/queries/super-users/render.ts | 72 +++++++++++++++---------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/src/queries/super-users/render.ts b/src/queries/super-users/render.ts index da60909e..781f7cc5 100644 --- a/src/queries/super-users/render.ts +++ b/src/queries/super-users/render.ts @@ -1,44 +1,44 @@ +import {pipe} from 'fp-ts/lib/function'; +import {html, joinHtml} from '../../types/html'; +import * as RA from 'fp-ts/ReadonlyArray'; import {ViewModel} from './view-model'; -import {pageTemplate} from '../../templates'; +import {displayDate} from '../../templates/display-date'; - -Handlebars.registerPartial( - 'super_users_table', - ` - {{#if superUsers}} - +const renderSuperUsers = (superUsers: ViewModel['superUsers']) => + pipe( + superUsers, + RA.map( + user => html` - - - + + + - {{#each superUsers}} + ` + ), + RA.match( + () => html`

    Currently no super-users

    `, + rows => html` +
    Member NumberSU since${user.memberNumber}${displayDate(user.since)} + + Revoke + +
    - - - + + + - {{/each}} -
    {{member_number this.memberNumber}}{{display_date this.since}} - - Revoke - - Member NumberSU since
    - {{else}} -

    Currently no super-users

    - {{/if}} - ` -); - -const SUPER_USERS_TEMPLATE = Handlebars.compile(` -

    Super-users

    - Declare a member to be a super-user - - {{> super_users_table}} -`); + ${joinHtml(rows)} + + ` + ) + ); export const render = (viewModel: ViewModel) => - pageTemplate( - 'Super Users', - viewModel.user - )(new SafeString(SUPER_USERS_TEMPLATE(viewModel))); + html` +

    Super-users

    + Declare a member to be a super-user + + ${renderSuperUsers(viewModel.superUsers)} + `; From 3028a4b8b9ea4f782206f847ec157cbc09efd6cd Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 13:04:01 +0100 Subject: [PATCH 29/45] Export HtmlSubstitution type and use UUID in more places to fix type errors around html templating --- src/authentication/auth-routes.ts | 3 ++- src/authentication/handlers.ts | 14 +++++----- src/http/email-handler.ts | 6 +++-- src/http/form-get.ts | 5 +++- src/http/form-post.ts | 5 +++- src/http/query-get.ts | 5 +++- src/http/router.ts | 3 ++- src/queries/equipment/construct-view-model.ts | 10 +++---- src/read-models/equipment/get.ts | 9 ++++--- src/templates/display-date.ts | 6 +++-- src/templates/oops.ts | 6 ++--- src/types/html.ts | 26 ++++++++++++------- 12 files changed, 60 insertions(+), 38 deletions(-) diff --git a/src/authentication/auth-routes.ts b/src/authentication/auth-routes.ts index 369f1529..dc6fac46 100644 --- a/src/authentication/auth-routes.ts +++ b/src/authentication/auth-routes.ts @@ -1,8 +1,9 @@ import {Dependencies} from '../dependencies'; +import {safe} from '../types/html'; import {Route, get, post} from '../types/route'; import {auth, callback, invalidLink, logIn, logOut} from './handlers'; -export const logInPath = '/log-in'; +export const logInPath = safe('/log-in'); const invalidLinkPath = '/auth/invalid-magic-link'; export const authRoutes = (deps: Dependencies): ReadonlyArray => { diff --git a/src/authentication/handlers.ts b/src/authentication/handlers.ts index 5a60d719..1687eb92 100644 --- a/src/authentication/handlers.ts +++ b/src/authentication/handlers.ts @@ -11,9 +11,9 @@ import {logInPage} from './log-in-page'; import {checkYourMailPage} from './check-your-mail'; import {oopsPage} from '../templates'; import {StatusCodes} from 'http-status-codes'; -import {SafeString} from 'handlebars'; import {getUserFromSession} from './get-user-from-session'; import {Dependencies} from '../dependencies'; +import {html, HtmlSubstitution, sanitizeString} from '../types/html'; export const logIn = (deps: Dependencies) => (req: Request, res: Response) => { pipe( @@ -39,7 +39,10 @@ export const auth = (req: Request, res: Response) => { parseEmailAddressFromBody, E.mapLeft(() => "You entered something that isn't a valid email address"), E.matchW( - msg => res.status(StatusCodes.BAD_REQUEST).send(oopsPage(msg)), + msg => + res + .status(StatusCodes.BAD_REQUEST) + .send(oopsPage(html`${sanitizeString(msg)}`)), email => { publish('send-log-in-link', email); res.status(StatusCodes.ACCEPTED).send(checkYourMailPage(email)); @@ -49,14 +52,13 @@ export const auth = (req: Request, res: Response) => { }; export const invalidLink = - (logInPath: string) => (req: Request, res: Response) => { + (logInPath: HtmlSubstitution) => (req: Request, res: Response) => { res .status(StatusCodes.UNAUTHORIZED) .send( oopsPage( - new SafeString( - `The link you have used is (no longer) valid. Go back to the log in` - ) + html`The link you have used is (no longer) valid. Go back to the + log in` ) ); }; diff --git a/src/http/email-handler.ts b/src/http/email-handler.ts index 8814d176..62579e23 100644 --- a/src/http/email-handler.ts +++ b/src/http/email-handler.ts @@ -11,7 +11,7 @@ import {Actor} from '../types'; import {Request, Response} from 'express'; import * as E from 'fp-ts/Either'; import {formatValidationErrors} from 'io-ts-reporters'; -import {html} from '../types/html'; +import {html, sanitizeString} from '../types/html'; import {SendEmail} from '../commands'; import {Config} from '../configuration'; import {isolatedPageTemplate} from '../templates/page-template'; @@ -73,7 +73,9 @@ const emailPost = failure, 'Failed to handle request to send an email' ); - res.status(failure.status).send(oopsPage(failure.message)); + res + .status(failure.status) + .send(oopsPage(sanitizeString(failure.message))); }, () => { res diff --git a/src/http/form-get.ts b/src/http/form-get.ts index 602f50a8..3b262564 100644 --- a/src/http/form-get.ts +++ b/src/http/form-get.ts @@ -8,6 +8,7 @@ import {oopsPage} from '../templates'; import {sequenceS} from 'fp-ts/lib/Apply'; import {Form} from '../types/form'; import {failureWithStatus} from '../types/failure-with-status'; +import {sanitizeString} from '../types/html'; const getUser = (req: Request, deps: Dependencies) => pipe( @@ -36,7 +37,9 @@ export const formGet = TE.matchW( failure => { deps.logger.error(failure, 'Failed to show form to a user'); - res.status(failure.status).send(oopsPage(failure.message)); + res + .status(failure.status) + .send(oopsPage(sanitizeString(failure.message))); }, page => res.status(200).send(page) ) diff --git a/src/http/form-post.ts b/src/http/form-post.ts index 30a659ac..98e224ee 100644 --- a/src/http/form-post.ts +++ b/src/http/form-post.ts @@ -13,6 +13,7 @@ import {Actor} from '../types/actor'; import {getUserFromSession} from '../authentication'; import {oopsPage} from '../templates'; import {applyToResource} from '../commands/apply-command-to-resource'; +import {sanitizeString} from '../types/html'; const getCommandFrom = (body: unknown, command: Command) => pipe( @@ -143,7 +144,9 @@ export const formPost = TE.match( failure => { deps.logger.error(failure, 'Failed to handle form submission'); - res.status(failure.status).send(oopsPage(failure.message)); + res + .status(failure.status) + .send(oopsPage(sanitizeString(failure.message))); }, () => res.redirect( diff --git a/src/http/query-get.ts b/src/http/query-get.ts index e3bbfe31..c62c231c 100644 --- a/src/http/query-get.ts +++ b/src/http/query-get.ts @@ -9,6 +9,7 @@ import {User, HttpResponse} from '../types'; import {oopsPage, templatePage} from '../templates'; import {Params, Query} from '../queries/query'; import {logInPath} from '../authentication/auth-routes'; +import {sanitizeString} from '../types/html'; const buildPage = (deps: Dependencies, params: Params, query: Query) => (user: User) => @@ -28,7 +29,9 @@ export const queryGet = deps.logger.error(failure, 'Failed respond to a query'); failure.status === StatusCodes.UNAUTHORIZED ? res.redirect(logInPath) - : res.status(failure.status).send(oopsPage(failure.message)); + : res + .status(failure.status) + .send(oopsPage(sanitizeString(failure.message))); }, HttpResponse.match({ Page: ({html}) => res.status(200).send(html), diff --git a/src/http/router.ts b/src/http/router.ts index a9ca5101..5322b4f4 100644 --- a/src/http/router.ts +++ b/src/http/router.ts @@ -5,6 +5,7 @@ import {StatusCodes} from 'http-status-codes'; import * as RA from 'fp-ts/ReadonlyArray'; import {pipe} from 'fp-ts/lib/function'; import {Route} from '../types/route'; +import {html} from '../types/html'; export const createRouter = (routes: ReadonlyArray): Router => { const router = Router(); @@ -21,7 +22,7 @@ export const createRouter = (routes: ReadonlyArray): Router => { router.use((req, res) => { res .status(StatusCodes.NOT_FOUND) - .send(oopsPage('The page you have requested does not exist.')); + .send(oopsPage(html`The page you have requested does not exist.`)); }); return router; }; diff --git a/src/queries/equipment/construct-view-model.ts b/src/queries/equipment/construct-view-model.ts index 78bbce8c..7b0a4625 100644 --- a/src/queries/equipment/construct-view-model.ts +++ b/src/queries/equipment/construct-view-model.ts @@ -19,11 +19,9 @@ import {DomainEvent, EventOfType} from '../../types/domain-event'; import {Equipment} from '../../read-models/equipment/get'; import {getMembersTrainedOn} from '../../read-models/equipment/get-trained-on'; import {DateTime} from 'luxon'; +import {UUID} from 'io-ts-types'; -const getEquipment = ( - events: ReadonlyArray, - equipmentId: string -) => +const getEquipment = (events: ReadonlyArray, equipmentId: UUID) => pipe( equipmentId, readModels.equipment.get(events), @@ -213,7 +211,7 @@ const getQuizResults = ( const isSuperUserOrOwnerOfArea = ( events: ReadonlyArray, - areaId: string, + areaId: UUID, memberNumber: number ) => TE.right( @@ -233,7 +231,7 @@ const isSuperUserOrTrainerOfEquipment = ( export const constructViewModel = (deps: Dependencies, user: User) => - (equipmentId: string): TE.TaskEither => + (equipmentId: UUID): TE.TaskEither => pipe( {user}, TE.right, diff --git a/src/read-models/equipment/get.ts b/src/read-models/equipment/get.ts index 8fb8e443..9b446f21 100644 --- a/src/read-models/equipment/get.ts +++ b/src/read-models/equipment/get.ts @@ -5,19 +5,20 @@ import {DomainEvent, SubsetOfDomainEvent, filterByName} from '../../types'; import * as RA from 'fp-ts/ReadonlyArray'; import {EventName, isEventOfType} from '../../types/domain-event'; import {Eq as stringEq} from 'fp-ts/string'; +import {UUID} from 'io-ts-types'; export type Equipment = { name: string; - id: string; + id: UUID; trainers: ReadonlyArray; - areaId: string; + areaId: UUID; trainedMembers: ReadonlyArray; }; type EquipmentState = { name: string; - id: string; - areaId: string; + id: UUID; + areaId: UUID; trainers: Set; trainedMembers: Set; }; diff --git a/src/templates/display-date.ts b/src/templates/display-date.ts index 56bec806..903b4f7e 100644 --- a/src/templates/display-date.ts +++ b/src/templates/display-date.ts @@ -1,8 +1,10 @@ import {DateTime} from 'luxon'; -import {Safe, safe} from '../types/html'; +import {HtmlSubstitution, safe} from '../types/html'; // TODO Do this properly. https://github.com/Makespace/members-app/issues/40 -export const displayDate = (date: DateTime | Date | number): Safe => +export const displayDate = ( + date: DateTime | Date | number +): HtmlSubstitution => typeof date === 'number' ? safe(DateTime.fromMillis(date).toLocaleString()) : safe(date.toLocaleString()); diff --git a/src/templates/oops.ts b/src/templates/oops.ts index 427348bd..d38085c0 100644 --- a/src/templates/oops.ts +++ b/src/templates/oops.ts @@ -1,8 +1,8 @@ -import {html, sanitizeString} from '../types/html'; +import {html, HtmlSubstitution} from '../types/html'; -export const oopsPage = (message: string) => html` +export const oopsPage = (message: HtmlSubstitution) => html`

    Sorry, we have encountered a problem

    -

    ${sanitizeString(message)}

    +

    ${message}

    Please try again. If the problem persists please reach out in the google group. diff --git a/src/types/html.ts b/src/types/html.ts index 2972cd1b..3437d979 100644 --- a/src/types/html.ts +++ b/src/types/html.ts @@ -5,28 +5,34 @@ import sanitize from 'sanitize-html'; export type Html = string & {readonly Html: unique symbol}; -type SanitizedString = string & {readonly SanitizedString: unique symbol}; +type SanitizedString = string & { + readonly SanitizedString: unique symbol; +}; export type Safe = string & {readonly Safe: unique symbol}; export const sanitizeString = (input: string): SanitizedString => sanitize(input) as SanitizedString; -export const joinHtml = ( - input: ReadonlyArray -) => input.join('\n') as Html; +export type HtmlSubstitution = + | Html + | number + | SanitizedString + | Safe + | UUID + | ''; + +export const joinHtml = (input: ReadonlyArray) => + input.join('\n') as Html; -export const commaHtml = ( - input: ReadonlyArray -) => input.join(', ') as Html; +export const commaHtml = (input: ReadonlyArray) => + input.join(', ') as Html; export const safe = (input: string): Safe => input as Safe; export const html = ( literals: TemplateStringsArray, - ...substitutions: ReadonlyArray< - Html | number | SanitizedString | Safe | UUID | '' - > + ...substitutions: ReadonlyArray ): Html => { if (literals.length === 1 && substitutions.length === 0) { return literals[0] as Html; From 026651896ae4e45a117e39e47e9509e21fad97e1 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 13:07:13 +0100 Subject: [PATCH 30/45] Use UUID in more places so we can trace that its safe for templating --- src/queries/area/construct-view-model.ts | 3 ++- src/queries/equipment/index.ts | 3 ++- src/read-models/areas/get-area.ts | 3 ++- src/read-models/equipment/get-all.ts | 5 +++-- src/read-models/equipment/get-for-area.ts | 5 +++-- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/queries/area/construct-view-model.ts b/src/queries/area/construct-view-model.ts index ede89e3a..d5ae28cf 100644 --- a/src/queries/area/construct-view-model.ts +++ b/src/queries/area/construct-view-model.ts @@ -11,10 +11,11 @@ import {ViewModel} from './view-model'; import {User} from '../../types'; import {StatusCodes} from 'http-status-codes'; import {sequenceS} from 'fp-ts/lib/Apply'; +import {UUID} from 'io-ts-types'; export const constructViewModel = (deps: Dependencies) => - (areaId: string, user: User): TE.TaskEither => + (areaId: UUID, user: User): TE.TaskEither => pipe( deps.getAllEvents(), TE.map(events => ({ diff --git a/src/queries/equipment/index.ts b/src/queries/equipment/index.ts index 4bb3584f..447e7aaf 100644 --- a/src/queries/equipment/index.ts +++ b/src/queries/equipment/index.ts @@ -9,6 +9,7 @@ import * as E from 'fp-ts/Either'; import {formatValidationErrors} from 'io-ts-reporters'; import {Query} from '../query'; import {HttpResponse} from '../../types'; +import {UUID} from 'io-ts-types'; const invalidParams = flow( formatValidationErrors, @@ -18,7 +19,7 @@ const invalidParams = flow( export const equipment: Query = deps => (user, params) => pipe( params, - t.strict({equipment: t.string}).decode, + t.strict({equipment: UUID}).decode, E.mapLeft(invalidParams), E.map(params => params.equipment), TE.fromEither, diff --git a/src/read-models/areas/get-area.ts b/src/read-models/areas/get-area.ts index b5a3d0b3..85d61d6f 100644 --- a/src/read-models/areas/get-area.ts +++ b/src/read-models/areas/get-area.ts @@ -5,6 +5,7 @@ import * as O from 'fp-ts/Option'; import {DomainEvent, SubsetOfDomainEvent, filterByName} from '../../types'; import * as RA from 'fp-ts/ReadonlyArray'; import {Area} from './area'; +import {UUID} from 'io-ts-types'; const pertinentEvents = ['AreaCreated' as const, 'OwnerAdded' as const]; @@ -32,7 +33,7 @@ const updateAreas = ( export const getArea = (events: ReadonlyArray) => - (areaId: string): O.Option => + (areaId: UUID): O.Option => pipe( events, filterByName(pertinentEvents), diff --git a/src/read-models/equipment/get-all.ts b/src/read-models/equipment/get-all.ts index c8963713..43fa8377 100644 --- a/src/read-models/equipment/get-all.ts +++ b/src/read-models/equipment/get-all.ts @@ -3,11 +3,12 @@ import * as O from 'fp-ts/Option'; import {DomainEvent, isEventOfType} from '../../types'; import * as RA from 'fp-ts/ReadonlyArray'; import {readModels} from '..'; +import {UUID} from 'io-ts-types'; type Equipment = { name: string; - id: string; - areaId: string; + id: UUID; + areaId: UUID; areaName: string; trainingSheetId: O.Option; }; diff --git a/src/read-models/equipment/get-for-area.ts b/src/read-models/equipment/get-for-area.ts index bc0f763d..23609735 100644 --- a/src/read-models/equipment/get-for-area.ts +++ b/src/read-models/equipment/get-for-area.ts @@ -1,11 +1,12 @@ import {pipe} from 'fp-ts/lib/function'; import {DomainEvent, isEventOfType} from '../../types'; import * as RA from 'fp-ts/ReadonlyArray'; +import {UUID} from 'io-ts-types'; type Equipment = { name: string; - id: string; - areaId: string; + id: UUID; + areaId: UUID; }; export const getForArea = From 43e9fb0c7cb290f092ce86f052e645e3111057ec Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 13:11:23 +0100 Subject: [PATCH 31/45] More references that should be UUID --- src/commands/equipment/add-form.ts | 2 +- src/queries/area/index.ts | 3 ++- src/types/html.ts | 2 +- tests/read-models/areas/get-area.test.ts | 4 +++- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/commands/equipment/add-form.ts b/src/commands/equipment/add-form.ts index 15ba48bc..441743f9 100644 --- a/src/commands/equipment/add-form.ts +++ b/src/commands/equipment/add-form.ts @@ -42,7 +42,7 @@ const getAreaId = (input: unknown) => E.map(({area}) => area) ); -const getAreaName = (events: ReadonlyArray, areaId: string) => +const getAreaName = (events: ReadonlyArray, areaId: UUID) => pipe( areaId, readModels.areas.getArea(events), diff --git a/src/queries/area/index.ts b/src/queries/area/index.ts index 8ebca06d..31c6d37d 100644 --- a/src/queries/area/index.ts +++ b/src/queries/area/index.ts @@ -9,6 +9,7 @@ import * as E from 'fp-ts/Either'; import {formatValidationErrors} from 'io-ts-reporters'; import {Query} from '../query'; import {HttpResponse} from '../../types'; +import {UUID} from 'io-ts-types'; const invalidParams = flow( formatValidationErrors, @@ -18,7 +19,7 @@ const invalidParams = flow( export const area: Query = deps => (user, params) => pipe( params, - t.strict({area: t.string}).decode, + t.strict({area: UUID}).decode, E.mapLeft(invalidParams), E.map(params => params.area), TE.fromEither, diff --git a/src/types/html.ts b/src/types/html.ts index 3437d979..e484adb0 100644 --- a/src/types/html.ts +++ b/src/types/html.ts @@ -9,7 +9,7 @@ type SanitizedString = string & { readonly SanitizedString: unique symbol; }; -export type Safe = string & {readonly Safe: unique symbol}; +type Safe = string & {readonly Safe: unique symbol}; export const sanitizeString = (input: string): SanitizedString => sanitize(input) as SanitizedString; diff --git a/tests/read-models/areas/get-area.test.ts b/tests/read-models/areas/get-area.test.ts index c893728c..dc5f8903 100644 --- a/tests/read-models/areas/get-area.test.ts +++ b/tests/read-models/areas/get-area.test.ts @@ -32,7 +32,9 @@ describe('get-area', () => { describe('when the area does not exist', () => { it('returns none', () => { - expect(getArea(events)(faker.string.uuid())).toStrictEqual(O.none); + expect(getArea(events)(faker.string.uuid() as UUID)).toStrictEqual( + O.none + ); }); }); }); From 475f32fe39a834b5f5bb4b51035aea7ff24c6ad2 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 13:15:06 +0100 Subject: [PATCH 32/45] Re-add memberInput usage --- src/commands/trainers/mark-member-trained-form.ts | 14 ++++++++++---- src/templates/member-input.ts | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/commands/trainers/mark-member-trained-form.ts b/src/commands/trainers/mark-member-trained-form.ts index f63373ec..b9569b98 100644 --- a/src/commands/trainers/mark-member-trained-form.ts +++ b/src/commands/trainers/mark-member-trained-form.ts @@ -1,17 +1,20 @@ import {pipe} from 'fp-ts/lib/function'; import * as E from 'fp-ts/Either'; import {html, sanitizeString} from '../../types/html'; -import {User} from '../../types'; +import {MemberDetails, User} from '../../types'; import {Form} from '../../types/form'; import {pageTemplate} from '../../templates'; import {getEquipmentName} from '../equipment/get-equipment-name'; import {getEquipmentIdFromForm} from '../equipment/get-equipment-id-from-form'; import {UUID} from 'io-ts-types'; +import {memberInput} from '../../templates/member-input'; +import {readModels} from '../../read-models'; type ViewModel = { user: User; equipmentId: UUID; equipmentName: string; + members: ReadonlyArray; }; // TODO - Drop down suggestion list of users. @@ -24,13 +27,12 @@ const renderForm = (viewModel: ViewModel) => Mark a member as trained on ${sanitizeString(viewModel.equipmentName)}

    - - + ${memberInput(viewModel.members)}
    `, @@ -46,7 +48,11 @@ const constructForm: Form['constructForm'] = E.bind('equipmentId', () => getEquipmentIdFromForm(input)), E.bind('equipmentName', ({equipmentId}) => getEquipmentName(events, equipmentId) - ) + ), + E.let('members', () => { + const memberDetails = readModels.members.getAllDetails(events); + return [...memberDetails.values()]; + }) ); export const markMemberTrainedForm: Form = { diff --git a/src/templates/member-input.ts b/src/templates/member-input.ts index 54483f4f..ff8c66ac 100644 --- a/src/templates/member-input.ts +++ b/src/templates/member-input.ts @@ -3,7 +3,7 @@ import {html, Html, optionalSafe, sanitizeString} from '../types/html'; import {getGravatarThumbnail} from './avatar'; import {filterList} from './filter-list'; -export const memberInputSelector = (member: MemberDetails): Html => html` +const memberInputSelector = (member: MemberDetails): Html => html`
    Date: Sat, 20 Jul 2024 13:19:46 +0100 Subject: [PATCH 33/45] Re-add usage of memberInput --- src/commands/super-user/declare-form.ts | 29 +++++++++++++++++++------ 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/commands/super-user/declare-form.ts b/src/commands/super-user/declare-form.ts index e2861e72..d005b92f 100644 --- a/src/commands/super-user/declare-form.ts +++ b/src/commands/super-user/declare-form.ts @@ -2,11 +2,14 @@ import {pipe} from 'fp-ts/lib/function'; import * as E from 'fp-ts/Either'; import {pageTemplate} from '../../templates'; import {html} from '../../types/html'; -import {User} from '../../types'; +import {MemberDetails, User} from '../../types'; import {Form} from '../../types/form'; +import {memberInput} from '../../templates/member-input'; +import {readModels} from '../../read-models'; type ViewModel = { user: User; + members: ReadonlyArray; }; const render = (viewModel: ViewModel) => @@ -17,17 +20,29 @@ const render = (viewModel: ViewModel) => - + ${memberInput(viewModel.members)} `, pageTemplate('Declare super user', viewModel.user) ); +const renderForm = (viewModel: ViewModel) => + pipe(viewModel, render, pageTemplate('Declare super user', viewModel.user)); + +export const constructForm: Form['constructForm'] = + () => + ({events, user}) => + pipe( + {user}, + E.right, + E.let('members', () => { + const memberDetails = readModels.members.getAllDetails(events); + return [...memberDetails.values()]; + }) + ); + export const declareForm: Form = { - renderForm: render, - constructForm: - () => - ({user}) => - E.right({user}), + renderForm, + constructForm, }; From 90abd84f02e3878beaa695e0521b7f692820fd45 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 13:22:58 +0100 Subject: [PATCH 34/45] Fix unused export --- src/commands/super-user/declare-form.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/super-user/declare-form.ts b/src/commands/super-user/declare-form.ts index d005b92f..5c4d7fe5 100644 --- a/src/commands/super-user/declare-form.ts +++ b/src/commands/super-user/declare-form.ts @@ -30,7 +30,7 @@ const render = (viewModel: ViewModel) => const renderForm = (viewModel: ViewModel) => pipe(viewModel, render, pageTemplate('Declare super user', viewModel.user)); -export const constructForm: Form['constructForm'] = +const constructForm: Form['constructForm'] = () => ({events, user}) => pipe( From f2603ebae795e495380af5a53b9d5353b9ffeca4 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 13:23:44 +0100 Subject: [PATCH 35/45] Need to type annotate safe() return because Safe was private --- src/authentication/auth-routes.ts | 4 ++-- src/types/html.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/authentication/auth-routes.ts b/src/authentication/auth-routes.ts index dc6fac46..a2d63a16 100644 --- a/src/authentication/auth-routes.ts +++ b/src/authentication/auth-routes.ts @@ -1,9 +1,9 @@ import {Dependencies} from '../dependencies'; -import {safe} from '../types/html'; +import {Safe, safe} from '../types/html'; import {Route, get, post} from '../types/route'; import {auth, callback, invalidLink, logIn, logOut} from './handlers'; -export const logInPath = safe('/log-in'); +export const logInPath: Safe = safe('/log-in'); const invalidLinkPath = '/auth/invalid-magic-link'; export const authRoutes = (deps: Dependencies): ReadonlyArray => { diff --git a/src/types/html.ts b/src/types/html.ts index e484adb0..3437d979 100644 --- a/src/types/html.ts +++ b/src/types/html.ts @@ -9,7 +9,7 @@ type SanitizedString = string & { readonly SanitizedString: unique symbol; }; -type Safe = string & {readonly Safe: unique symbol}; +export type Safe = string & {readonly Safe: unique symbol}; export const sanitizeString = (input: string): SanitizedString => sanitize(input) as SanitizedString; From 4cb4e70a5c30730646c17458f932ab685ff8724b Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 13:31:59 +0100 Subject: [PATCH 36/45] Fix isolated pages not being rendered as templates --- src/authentication/check-your-mail.ts | 22 ++++++++++++++-------- src/authentication/log-in-page.ts | 23 ++++++++++++++--------- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/src/authentication/check-your-mail.ts b/src/authentication/check-your-mail.ts index 08b6675d..669c1309 100644 --- a/src/authentication/check-your-mail.ts +++ b/src/authentication/check-your-mail.ts @@ -1,10 +1,16 @@ +import {pipe} from 'fp-ts/lib/function'; +import {isolatedPageTemplate} from '../templates/page-template'; import {html, sanitizeString} from '../types/html'; -export const checkYourMailPage = (submittedEmailAddress: string) => html` -

    Check your mail

    -

    - If ${sanitizeString(submittedEmailAddress)} is linked to a Makespace - number you should receive an email with that number. -

    -

    If nothing happens please reach out to the Makespace Database Team.

    -`; +export const checkYourMailPage = (submittedEmailAddress: string) => + pipe( + html` +

    Check your mail

    +

    + If ${sanitizeString(submittedEmailAddress)} is linked to a + Makespace number you should receive an email with that number. +

    +

    If nothing happens please reach out to the Makespace Database Team.

    + `, + isolatedPageTemplate('Check your mail') + ); diff --git a/src/authentication/log-in-page.ts b/src/authentication/log-in-page.ts index 2439f6df..94736b1c 100644 --- a/src/authentication/log-in-page.ts +++ b/src/authentication/log-in-page.ts @@ -1,11 +1,16 @@ +import {pipe} from 'fp-ts/lib/function'; import {html} from '../types/html'; +import {isolatedPageTemplate} from '../templates/page-template'; -export const logInPage = () => html` -

    Log in

    -
    - - -

    We will email you a magic log in link.

    - -
    -`; +export const logInPage = pipe( + html` +

    Log in

    +
    + + +

    We will email you a magic log in link.

    + +
    + `, + isolatedPageTemplate('Login') +); From 40b4b5e024ff7a0c12cbcf01d1148068d7d29f04 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 13:43:57 +0100 Subject: [PATCH 37/45] Enforce that only rendered html gets returned --- src/authentication/handlers.ts | 39 ++++++++++++++++++++-------------- src/http/email-handler.ts | 4 ++-- src/http/form-get.ts | 4 ++-- src/http/form-post.ts | 4 ++-- src/http/query-get.ts | 7 +++--- src/queries/landing/index.ts | 2 +- src/queries/landing/render.ts | 4 +++- src/templates/oops.ts | 22 ++++++++++++------- src/templates/page-template.ts | 31 ++++++++++++++------------- src/types/form.ts | 3 ++- src/types/html.ts | 4 +++- 11 files changed, 72 insertions(+), 52 deletions(-) diff --git a/src/authentication/handlers.ts b/src/authentication/handlers.ts index 1687eb92..e3afc210 100644 --- a/src/authentication/handlers.ts +++ b/src/authentication/handlers.ts @@ -13,27 +13,33 @@ import {oopsPage} from '../templates'; import {StatusCodes} from 'http-status-codes'; import {getUserFromSession} from './get-user-from-session'; import {Dependencies} from '../dependencies'; -import {html, HtmlSubstitution, sanitizeString} from '../types/html'; +import { + html, + HtmlSubstitution, + RenderedHtml, + sanitizeString, +} from '../types/html'; -export const logIn = (deps: Dependencies) => (req: Request, res: Response) => { - pipe( - req.session, - getUserFromSession(deps), - O.match( - () => { - res.status(StatusCodes.OK).send(logInPage); - }, - _user => res.redirect('/') - ) - ); -}; +export const logIn = + (deps: Dependencies) => (req: Request, res: Response) => { + pipe( + req.session, + getUserFromSession(deps), + O.match( + () => { + res.status(StatusCodes.OK).send(logInPage); + }, + _user => res.redirect('/') + ) + ); + }; -export const logOut = (req: Request, res: Response) => { +export const logOut = (req: Request, res: Response) => { req.session = null; res.redirect('/'); }; -export const auth = (req: Request, res: Response) => { +export const auth = (req: Request, res: Response) => { pipe( req.body, parseEmailAddressFromBody, @@ -52,7 +58,8 @@ export const auth = (req: Request, res: Response) => { }; export const invalidLink = - (logInPath: HtmlSubstitution) => (req: Request, res: Response) => { + (logInPath: HtmlSubstitution) => + (req: Request, res: Response) => { res .status(StatusCodes.UNAUTHORIZED) .send( diff --git a/src/http/email-handler.ts b/src/http/email-handler.ts index 62579e23..1a9514f8 100644 --- a/src/http/email-handler.ts +++ b/src/http/email-handler.ts @@ -11,7 +11,7 @@ import {Actor} from '../types'; import {Request, Response} from 'express'; import * as E from 'fp-ts/Either'; import {formatValidationErrors} from 'io-ts-reporters'; -import {html, sanitizeString} from '../types/html'; +import {html, RenderedHtml, sanitizeString} from '../types/html'; import {SendEmail} from '../commands'; import {Config} from '../configuration'; import {isolatedPageTemplate} from '../templates/page-template'; @@ -39,7 +39,7 @@ const getInput = (body: unknown, command: SendEmail) => const emailPost = (conf: Config, deps: Dependencies, command: SendEmail) => - async (req: Request, res: Response) => { + async (req: Request, res: Response) => { await pipe( { actor: getActorFrom(req.session, deps), diff --git a/src/http/form-get.ts b/src/http/form-get.ts index 3b262564..a290ff34 100644 --- a/src/http/form-get.ts +++ b/src/http/form-get.ts @@ -8,7 +8,7 @@ import {oopsPage} from '../templates'; import {sequenceS} from 'fp-ts/lib/Apply'; import {Form} from '../types/form'; import {failureWithStatus} from '../types/failure-with-status'; -import {sanitizeString} from '../types/html'; +import {RenderedHtml, sanitizeString} from '../types/html'; const getUser = (req: Request, deps: Dependencies) => pipe( @@ -25,7 +25,7 @@ const getUser = (req: Request, deps: Dependencies) => // is where conflict resolution etc. is handled as described in form-post. export const formGet = (deps: Dependencies, form: Form) => - async (req: Request, res: Response) => { + async (req: Request, res: Response) => { await pipe( { user: getUser(req, deps), diff --git a/src/http/form-post.ts b/src/http/form-post.ts index 98e224ee..7ef07ac3 100644 --- a/src/http/form-post.ts +++ b/src/http/form-post.ts @@ -13,7 +13,7 @@ import {Actor} from '../types/actor'; import {getUserFromSession} from '../authentication'; import {oopsPage} from '../templates'; import {applyToResource} from '../commands/apply-command-to-resource'; -import {sanitizeString} from '../types/html'; +import {RenderedHtml, sanitizeString} from '../types/html'; const getCommandFrom = (body: unknown, command: Command) => pipe( @@ -58,7 +58,7 @@ const nextCodec = t.strict({next: path}); export const formPost = (deps: Dependencies, command: Command, successTarget: string) => - async (req: Request, res: Response) => { + async (req: Request, res: Response) => { // Look at comments to see the core ideas of this pipe / how this works. await pipe( { diff --git a/src/http/query-get.ts b/src/http/query-get.ts index c62c231c..e9161285 100644 --- a/src/http/query-get.ts +++ b/src/http/query-get.ts @@ -9,14 +9,15 @@ import {User, HttpResponse} from '../types'; import {oopsPage, templatePage} from '../templates'; import {Params, Query} from '../queries/query'; import {logInPath} from '../authentication/auth-routes'; -import {sanitizeString} from '../types/html'; +import {RenderedHtml, sanitizeString} from '../types/html'; const buildPage = (deps: Dependencies, params: Params, query: Query) => (user: User) => pipe(query(deps)(user, params), TE.map(templatePage)); export const queryGet = - (deps: Dependencies, query: Query) => async (req: Request, res: Response) => { + (deps: Dependencies, query: Query) => + async (req: Request, res: Response) => { await pipe( req.session, getUserFromSession(deps), @@ -34,7 +35,7 @@ export const queryGet = .send(oopsPage(sanitizeString(failure.message))); }, HttpResponse.match({ - Page: ({html}) => res.status(200).send(html), + Page: ({rendered}) => res.status(200).send(rendered), Redirect: ({url}) => res.redirect(url), }) ) diff --git a/src/queries/landing/index.ts b/src/queries/landing/index.ts index 7ef93be7..39aa5279 100644 --- a/src/queries/landing/index.ts +++ b/src/queries/landing/index.ts @@ -10,5 +10,5 @@ export const landing: Query = deps => user => user, constructViewModel(deps), TE.map(render), - TE.map(html => HttpResponse.mk.Page({html})) + TE.map(rendered => HttpResponse.mk.Page({rendered})) ); diff --git a/src/queries/landing/render.ts b/src/queries/landing/render.ts index bbd8c110..e1a67cbe 100644 --- a/src/queries/landing/render.ts +++ b/src/queries/landing/render.ts @@ -1,6 +1,7 @@ import {pipe} from 'fp-ts/lib/function'; import {html, sanitizeString} from '../../types/html'; import {ViewModel} from './view-model'; +import {pageTemplate} from '../../templates'; const renderMemberDetails = (user: ViewModel['user']) => html`
    @@ -40,5 +41,6 @@ export const render = (viewModel: ViewModel) => ${renderMemberDetails(viewModel.user)} ${viewModel.isSuperUser ? superUserNav : ''} - ` + `, + pageTemplate('Makespace Member Dashboard', viewModel.user) ); diff --git a/src/templates/oops.ts b/src/templates/oops.ts index d38085c0..21c6012a 100644 --- a/src/templates/oops.ts +++ b/src/templates/oops.ts @@ -1,10 +1,16 @@ +import {pipe} from 'fp-ts/lib/function'; import {html, HtmlSubstitution} from '../types/html'; +import {isolatedPageTemplate} from './page-template'; -export const oopsPage = (message: HtmlSubstitution) => html` -

    Sorry, we have encountered a problem

    -

    ${message}

    -

    - Please try again. If the problem persists please reach out in the google - group. -

    -`; +export const oopsPage = (message: HtmlSubstitution) => + pipe( + html` +

    Sorry, we have encountered a problem

    +

    ${message}

    +

    + Please try again. If the problem persists please reach out in the google + group. +

    + `, + isolatedPageTemplate('Oops') + ); diff --git a/src/templates/page-template.ts b/src/templates/page-template.ts index 27107f35..be340e9e 100644 --- a/src/templates/page-template.ts +++ b/src/templates/page-template.ts @@ -1,12 +1,12 @@ import {HttpResponse, Member} from '../types'; -import {html, Html} from '../types/html'; +import {html, Html, RenderedHtml} from '../types/html'; import {gridJs} from './grid-js'; import {head} from './head'; import {navBar} from './navbar'; -export const pageTemplate = - (title: string, user: Member) => (body: Html) => html` +export const pageTemplate = (title: string, user: Member) => (body: Html) => + html` ${head(title)} @@ -15,24 +15,25 @@ export const pageTemplate = ${body} ${gridJs()} - `; + ` as RenderedHtml; // For pages not part of the normal flow. -export const isolatedPageTemplate = (title: string) => (body: Html) => html` - - - ${head(title)} - - ${body} ${gridJs()} - - -`; +export const isolatedPageTemplate = (title: string) => (body: Html) => + html` + + + ${head(title)} + + ${body} ${gridJs()} + + + ` as RenderedHtml; export const templatePage: (r: HttpResponse) => HttpResponse = HttpResponse.match({ Redirect: HttpResponse.mk.Redirect, - Page: ({html}) => + Page: ({rendered}) => HttpResponse.mk.Page({ - html, + rendered, }), }); diff --git a/src/types/form.ts b/src/types/form.ts index 448880f4..94e940ae 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -1,9 +1,10 @@ import {DomainEvent, User} from '.'; import {FailureWithStatus} from './failure-with-status'; import * as E from 'fp-ts/Either'; +import {RenderedHtml} from './html'; export type Form = { - renderForm: (viewModel: T) => string; + renderForm: (viewModel: T) => RenderedHtml; constructForm: ( input: unknown ) => (context: { diff --git a/src/types/html.ts b/src/types/html.ts index 3437d979..6253a343 100644 --- a/src/types/html.ts +++ b/src/types/html.ts @@ -14,6 +14,8 @@ export type Safe = string & {readonly Safe: unique symbol}; export const sanitizeString = (input: string): SanitizedString => sanitize(input) as SanitizedString; +export type RenderedHtml = Html & {readonly RenderedHtml: unique symbol}; + export type HtmlSubstitution = | Html | number @@ -56,7 +58,7 @@ export const optionalSafe = ( : safe('-'); interface Page { - html: Html; + rendered: RenderedHtml; } interface Redirect { From 89427f39d6c5043ab267f9c5e228e6c5a9e7d803 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 13:54:07 +0100 Subject: [PATCH 38/45] Fix areas retuning non-rendered html --- src/queries/all-equipment/index.ts | 2 +- src/queries/all-equipment/render.ts | 17 +++-- src/queries/area/index.ts | 2 +- src/queries/area/render.ts | 20 ++++-- src/queries/equipment/index.ts | 2 +- src/queries/failed-imports/index.ts | 2 +- src/queries/failed-imports/render.ts | 23 +++--- src/queries/log/index.ts | 2 +- src/queries/log/render.ts | 17 +++-- src/queries/member/render.ts | 104 ++++++++++++++------------- src/queries/members/index.ts | 2 +- src/queries/members/render.ts | 21 ++++-- src/queries/super-users/index.ts | 2 +- src/queries/super-users/render.ts | 18 +++-- src/templates/head.ts | 6 +- src/templates/page-template.ts | 27 +++---- 16 files changed, 155 insertions(+), 112 deletions(-) diff --git a/src/queries/all-equipment/index.ts b/src/queries/all-equipment/index.ts index 8fb972b6..2ef1a1ee 100644 --- a/src/queries/all-equipment/index.ts +++ b/src/queries/all-equipment/index.ts @@ -10,5 +10,5 @@ export const allEquipment: Query = deps => user => user, constructViewModel(deps), TE.map(render), - TE.map(html => HttpResponse.mk.Page({html})) + TE.map(rendered => HttpResponse.mk.Page({rendered})) ); diff --git a/src/queries/all-equipment/render.ts b/src/queries/all-equipment/render.ts index f6a25d87..79a04e47 100644 --- a/src/queries/all-equipment/render.ts +++ b/src/queries/all-equipment/render.ts @@ -1,7 +1,8 @@ import {pipe} from 'fp-ts/lib/function'; -import {html, joinHtml, sanitizeString} from '../../types/html'; +import {html, joinHtml, safe, sanitizeString} from '../../types/html'; import * as RA from 'fp-ts/ReadonlyArray'; import {ViewModel} from './view-model'; +import {pageTemplate} from '../../templates'; const renderEquipment = (allEquipment: ViewModel['equipment']) => pipe( @@ -44,8 +45,12 @@ const addAreaCallToAction = html` Add area of responsibility `; -export const render = (viewModel: ViewModel) => html` -

    Equipment of Makespace

    - ${viewModel.isSuperUser ? addAreaCallToAction : ''} - ${renderEquipment(viewModel.equipment)} -`; +export const render = (viewModel: ViewModel) => + pipe( + html` +

    Equipment of Makespace

    + ${viewModel.isSuperUser ? addAreaCallToAction : ''} + ${renderEquipment(viewModel.equipment)} + `, + pageTemplate(safe('Equipment'), viewModel.user) + ); diff --git a/src/queries/area/index.ts b/src/queries/area/index.ts index 31c6d37d..d817358b 100644 --- a/src/queries/area/index.ts +++ b/src/queries/area/index.ts @@ -26,7 +26,7 @@ export const area: Query = deps => (user, params) => TE.chain(areaId => constructViewModel(deps)(areaId, user)), TE.map(viewModel => HttpResponse.mk.Page({ - html: render(viewModel), + rendered: render(viewModel), }) ) ); diff --git a/src/queries/area/render.ts b/src/queries/area/render.ts index ed83bc5e..dbe07a34 100644 --- a/src/queries/area/render.ts +++ b/src/queries/area/render.ts @@ -3,6 +3,7 @@ import {html, joinHtml, sanitizeString} from '../../types/html'; import {ViewModel} from './view-model'; import * as RA from 'fp-ts/ReadonlyArray'; import {UUID} from 'io-ts-types'; +import {pageTemplate} from '../../templates'; const renderOwners = (owners: ViewModel['area']['owners']) => pipe( @@ -44,10 +45,15 @@ const renderEquipment = (allEquipment: ViewModel['equipment']) => ); export const render = (viewModel: ViewModel) => - html`

    ${sanitizeString(viewModel.area.name)}

    - ${viewModel.isSuperUser ? addEquipmentCallToAction(viewModel.area.id) : ''} -

    Owners

    - ${viewModel.isSuperUser ? addOwnerCallToAction(viewModel.area.id) : ''} - ${renderOwners(viewModel.area.owners)} -

    Equipment

    - ${renderEquipment(viewModel.equipment)} `; + pipe( + html`

    ${sanitizeString(viewModel.area.name)}

    + ${viewModel.isSuperUser + ? addEquipmentCallToAction(viewModel.area.id) + : ''} +

    Owners

    + ${viewModel.isSuperUser ? addOwnerCallToAction(viewModel.area.id) : ''} + ${renderOwners(viewModel.area.owners)} +

    Equipment

    + ${renderEquipment(viewModel.equipment)} `, + pageTemplate(sanitizeString(viewModel.area.name), viewModel.user) + ); diff --git a/src/queries/equipment/index.ts b/src/queries/equipment/index.ts index 447e7aaf..c63ec473 100644 --- a/src/queries/equipment/index.ts +++ b/src/queries/equipment/index.ts @@ -26,7 +26,7 @@ export const equipment: Query = deps => (user, params) => TE.chain(constructViewModel(deps, user)), TE.map(viewModel => HttpResponse.mk.Page({ - html: render(viewModel), + rendered: render(viewModel), }) ) ); diff --git a/src/queries/failed-imports/index.ts b/src/queries/failed-imports/index.ts index 78c86ab2..08b7b8b5 100644 --- a/src/queries/failed-imports/index.ts +++ b/src/queries/failed-imports/index.ts @@ -10,5 +10,5 @@ export const failedImports: Query = deps => user => user, constructViewModel(deps), TE.map(render), - TE.map(html => HttpResponse.mk.Page({html})) + TE.map(rendered => HttpResponse.mk.Page({rendered})) ); diff --git a/src/queries/failed-imports/render.ts b/src/queries/failed-imports/render.ts index 0a26f8a1..962e4be3 100644 --- a/src/queries/failed-imports/render.ts +++ b/src/queries/failed-imports/render.ts @@ -1,7 +1,8 @@ import {pipe} from 'fp-ts/lib/function'; -import {html, joinHtml, sanitizeString} from '../../types/html'; +import {html, joinHtml, safe, sanitizeString} from '../../types/html'; import {ViewModel} from './view-model'; import * as RA from 'fp-ts/ReadonlyArray'; +import {pageTemplate} from '../../templates'; const renderFailedLinkings = (failedImports: ViewModel['failedImports']) => pipe( @@ -19,11 +20,15 @@ const renderFailedLinkings = (failedImports: ViewModel['failedImports']) => ` ); -export const render = (viewModel: ViewModel) => html` -

    Failed member imports

    -

    - During import from the legacy database the following members could not be - imported because the email address is already used by another member. -

    - ${renderFailedLinkings(viewModel.failedImports)} -`; +export const render = (viewModel: ViewModel) => + pipe( + html` +

    Failed member imports

    +

    + During import from the legacy database the following members could not + be imported because the email address is already used by another member. +

    + ${renderFailedLinkings(viewModel.failedImports)} + `, + pageTemplate(safe('Failed member imports'), viewModel.user) + ); diff --git a/src/queries/log/index.ts b/src/queries/log/index.ts index a470376d..7771d610 100644 --- a/src/queries/log/index.ts +++ b/src/queries/log/index.ts @@ -10,5 +10,5 @@ export const log: Query = deps => user => user, constructViewModel(deps), TE.map(render), - TE.map(html => HttpResponse.mk.Page({html})) + TE.map(rendered => HttpResponse.mk.Page({rendered})) ); diff --git a/src/queries/log/render.ts b/src/queries/log/render.ts index c57c2978..ad1c6669 100644 --- a/src/queries/log/render.ts +++ b/src/queries/log/render.ts @@ -1,11 +1,12 @@ import {pipe} from 'fp-ts/lib/function'; import * as RA from 'fp-ts/ReadonlyArray'; -import {html, joinHtml, sanitizeString} from '../../types/html'; +import {html, joinHtml, safe, sanitizeString} from '../../types/html'; import {ViewModel} from './view-model'; import {Actor} from '../../types/actor'; import {DomainEvent} from '../../types'; import {inspect} from 'node:util'; import {displayDate} from '../../templates/display-date'; +import {pageTemplate} from '../../templates'; const renderActor = (actor: Actor) => { switch (actor.tag) { @@ -50,8 +51,12 @@ const renderLog = (log: ViewModel['events']) => ` ); -export const render = (viewModel: ViewModel) => html` -

    Event log

    -

    Most recent at top

    - ${renderLog(viewModel.events)} -`; +export const render = (viewModel: ViewModel) => + pipe( + html` +

    Event log

    +

    Most recent at top

    + ${renderLog(viewModel.events)} + `, + pageTemplate(safe('Event Log'), viewModel.user) + ); diff --git a/src/queries/member/render.ts b/src/queries/member/render.ts index 73f920b4..2cdcf382 100644 --- a/src/queries/member/render.ts +++ b/src/queries/member/render.ts @@ -1,6 +1,8 @@ +import {pipe} from 'fp-ts/lib/function'; import {getGravatarProfile, getGravatarThumbnail} from '../../templates/avatar'; -import {Html, html, optionalSafe, sanitizeString} from '../../types/html'; +import {Html, html, optionalSafe, safe, sanitizeString} from '../../types/html'; import {ViewModel} from './view-model'; +import {pageTemplate} from '../../templates'; const ownPageBanner = html`

    This is your profile!

    `; @@ -20,51 +22,55 @@ const editAvatar = () => const ifSelf = (viewModel: ViewModel, fragment: Html) => viewModel.isSelf ? fragment : ''; -export const render = (viewModel: ViewModel) => html` - ${ifSelf(viewModel, ownPageBanner)} -
    - ${getGravatarProfile( - viewModel.member.emailAddress, - viewModel.member.memberNumber - )} -
    - - - - - - - - - - - - - - - - - - - - - - - - -
    - Details -
    Member number${viewModel.member.memberNumber}
    Email${sanitizeString(viewModel.member.emailAddress)}
    Name - ${optionalSafe(viewModel.member.name)} - ${ifSelf(viewModel, editName(viewModel))} -
    Pronouns - ${optionalSafe(viewModel.member.pronouns)} - ${ifSelf(viewModel, editPronouns(viewModel))} -
    Avatar - ${getGravatarThumbnail( - viewModel.member.emailAddress, - viewModel.member.memberNumber - )} - ${ifSelf(viewModel, editAvatar())} -
    -`; +export const render = (viewModel: ViewModel) => + pipe( + html` + ${ifSelf(viewModel, ownPageBanner)} +
    + ${getGravatarProfile( + viewModel.member.emailAddress, + viewModel.member.memberNumber + )} +
    + + + + + + + + + + + + + + + + + + + + + + + + +
    + Details +
    Member number${viewModel.member.memberNumber}
    Email${sanitizeString(viewModel.member.emailAddress)}
    Name + ${optionalSafe(viewModel.member.name)} + ${ifSelf(viewModel, editName(viewModel))} +
    Pronouns + ${optionalSafe(viewModel.member.pronouns)} + ${ifSelf(viewModel, editPronouns(viewModel))} +
    Avatar + ${getGravatarThumbnail( + viewModel.member.emailAddress, + viewModel.member.memberNumber + )} + ${ifSelf(viewModel, editAvatar())} +
    + `, + pageTemplate(safe('Member'), viewModel.user) + ); diff --git a/src/queries/members/index.ts b/src/queries/members/index.ts index 15447a43..8146d07d 100644 --- a/src/queries/members/index.ts +++ b/src/queries/members/index.ts @@ -10,5 +10,5 @@ export const members: Query = deps => user => user, constructViewModel(deps), TE.map(render), - TE.map(html => HttpResponse.mk.Page({html})) + TE.map(rendered => HttpResponse.mk.Page({rendered})) ); diff --git a/src/queries/members/render.ts b/src/queries/members/render.ts index e5ca309f..4cdc48d2 100644 --- a/src/queries/members/render.ts +++ b/src/queries/members/render.ts @@ -1,9 +1,16 @@ import {pipe} from 'fp-ts/lib/function'; -import {html, joinHtml, optionalSafe, sanitizeString} from '../../types/html'; +import { + html, + joinHtml, + optionalSafe, + safe, + sanitizeString, +} from '../../types/html'; import * as RA from 'fp-ts/ReadonlyArray'; import {ViewModel} from './view-model'; import {getGravatarThumbnail} from '../../templates/avatar'; import {renderMemberNumber} from '../../templates/member-number'; +import {pageTemplate} from '../../templates'; const renderMembers = (viewModel: ViewModel) => pipe( @@ -46,7 +53,11 @@ const renderMembers = (viewModel: ViewModel) => ) ); -export const render = (viewModel: ViewModel) => html` -

    Members of Makespace

    - ${renderMembers(viewModel)} -`; +export const render = (viewModel: ViewModel) => + pipe( + html` +

    Members of Makespace

    + ${renderMembers(viewModel)} + `, + pageTemplate(safe('Members'), viewModel.user) + ); diff --git a/src/queries/super-users/index.ts b/src/queries/super-users/index.ts index 2ef1d0ea..1cf80ca8 100644 --- a/src/queries/super-users/index.ts +++ b/src/queries/super-users/index.ts @@ -10,5 +10,5 @@ export const superUsers: Query = deps => user => user, constructViewModel(deps), TE.map(render), - TE.map(html => HttpResponse.mk.Page({html})) + TE.map(rendered => HttpResponse.mk.Page({rendered})) ); diff --git a/src/queries/super-users/render.ts b/src/queries/super-users/render.ts index 781f7cc5..c4f0ed6c 100644 --- a/src/queries/super-users/render.ts +++ b/src/queries/super-users/render.ts @@ -1,8 +1,9 @@ import {pipe} from 'fp-ts/lib/function'; -import {html, joinHtml} from '../../types/html'; +import {html, joinHtml, safe} from '../../types/html'; import * as RA from 'fp-ts/ReadonlyArray'; import {ViewModel} from './view-model'; import {displayDate} from '../../templates/display-date'; +import {pageTemplate} from '../../templates'; const renderSuperUsers = (superUsers: ViewModel['superUsers']) => pipe( @@ -36,9 +37,12 @@ const renderSuperUsers = (superUsers: ViewModel['superUsers']) => ); export const render = (viewModel: ViewModel) => - html` -

    Super-users

    - Declare a member to be a super-user - - ${renderSuperUsers(viewModel.superUsers)} - `; + pipe( + html` +

    Super-users

    + Declare a member to be a super-user + + ${renderSuperUsers(viewModel.superUsers)} + `, + pageTemplate(safe('Super users'), viewModel.user) + ); diff --git a/src/templates/head.ts b/src/templates/head.ts index e20e7dcf..4def56ff 100644 --- a/src/templates/head.ts +++ b/src/templates/head.ts @@ -1,11 +1,11 @@ -import {html, sanitizeString} from '../types/html'; +import {html, HtmlSubstitution} from '../types/html'; -export const head = (title: string) => html` +export const head = (title: HtmlSubstitution) => html` - ${sanitizeString(title)} | Cambridge Makespace + ${title} | Cambridge Makespace diff --git a/src/templates/page-template.ts b/src/templates/page-template.ts index be340e9e..32723c98 100644 --- a/src/templates/page-template.ts +++ b/src/templates/page-template.ts @@ -1,24 +1,25 @@ import {HttpResponse, Member} from '../types'; -import {html, Html, RenderedHtml} from '../types/html'; +import {html, Html, HtmlSubstitution, RenderedHtml} from '../types/html'; import {gridJs} from './grid-js'; import {head} from './head'; import {navBar} from './navbar'; -export const pageTemplate = (title: string, user: Member) => (body: Html) => - html` - - - ${head(title)} -
    ${navBar(user)}
    - - ${body} ${gridJs()} - - - ` as RenderedHtml; +export const pageTemplate = + (title: HtmlSubstitution, user: Member) => (body: Html) => + html` + + + ${head(title)} +
    ${navBar(user)}
    + + ${body} ${gridJs()} + + + ` as RenderedHtml; // For pages not part of the normal flow. -export const isolatedPageTemplate = (title: string) => (body: Html) => +export const isolatedPageTemplate = (title: HtmlSubstitution) => (body: Html) => html` From 2b8a6fa1f6a8f8a5a79dadc24bfabe4e83fe4955 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 14:02:11 +0100 Subject: [PATCH 39/45] Mark titles safe or sanitise them --- src/authentication/check-your-mail.ts | 4 ++-- src/authentication/log-in-page.ts | 4 ++-- src/commands/area/add-owner-form.ts | 2 +- src/commands/area/create-form.ts | 4 ++-- src/commands/equipment/add-form.ts | 4 ++-- .../equipment/register-training-sheet-form.ts | 4 ++-- .../link-number-to-email-form.ts | 7 ++++-- src/commands/members/edit-name-form.ts | 4 ++-- src/commands/members/edit-pronouns-form.ts | 4 ++-- .../members/sign-owner-agreement-form.ts | 2 +- src/commands/super-user/declare-form.ts | 10 +++++--- src/commands/super-user/revoke-form.ts | 4 ++-- src/commands/trainers/add-trainer-form.ts | 4 ++-- .../trainers/mark-member-trained-form.ts | 4 ++-- src/http/email-handler.ts | 4 ++-- src/queries/areas/index.ts | 2 +- src/queries/areas/render.ts | 23 ++++++++++++++----- src/queries/equipment/render.ts | 2 +- src/queries/landing/render.ts | 4 ++-- src/queries/member/index.ts | 4 ++-- src/templates/oops.ts | 4 ++-- 21 files changed, 61 insertions(+), 43 deletions(-) diff --git a/src/authentication/check-your-mail.ts b/src/authentication/check-your-mail.ts index 669c1309..66d10938 100644 --- a/src/authentication/check-your-mail.ts +++ b/src/authentication/check-your-mail.ts @@ -1,6 +1,6 @@ import {pipe} from 'fp-ts/lib/function'; import {isolatedPageTemplate} from '../templates/page-template'; -import {html, sanitizeString} from '../types/html'; +import {html, safe, sanitizeString} from '../types/html'; export const checkYourMailPage = (submittedEmailAddress: string) => pipe( @@ -12,5 +12,5 @@ export const checkYourMailPage = (submittedEmailAddress: string) =>

    If nothing happens please reach out to the Makespace Database Team.

    `, - isolatedPageTemplate('Check your mail') + isolatedPageTemplate(safe('Check your mail')) ); diff --git a/src/authentication/log-in-page.ts b/src/authentication/log-in-page.ts index 94736b1c..a6977ec6 100644 --- a/src/authentication/log-in-page.ts +++ b/src/authentication/log-in-page.ts @@ -1,5 +1,5 @@ import {pipe} from 'fp-ts/lib/function'; -import {html} from '../types/html'; +import {html, safe} from '../types/html'; import {isolatedPageTemplate} from '../templates/page-template'; export const logInPage = pipe( @@ -12,5 +12,5 @@ export const logInPage = pipe( `, - isolatedPageTemplate('Login') + isolatedPageTemplate(safe('Login')) ); diff --git a/src/commands/area/add-owner-form.ts b/src/commands/area/add-owner-form.ts index da8208ed..b1380777 100644 --- a/src/commands/area/add-owner-form.ts +++ b/src/commands/area/add-owner-form.ts @@ -108,7 +108,7 @@ const renderBody = (viewModel: ViewModel) => html` `; const renderForm = (viewModel: ViewModel) => - pipe(viewModel, renderBody, pageTemplate('Add Owner', viewModel.user)); + pipe(viewModel, renderBody, pageTemplate(safe('Add Owner'), viewModel.user)); const paramsCodec = t.strict({ area: t.string, diff --git a/src/commands/area/create-form.ts b/src/commands/area/create-form.ts index 88efda67..1ffa549a 100644 --- a/src/commands/area/create-form.ts +++ b/src/commands/area/create-form.ts @@ -3,7 +3,7 @@ import {pageTemplate} from '../../templates'; import {User} from '../../types'; import {Form} from '../../types/form'; import {pipe} from 'fp-ts/lib/function'; -import {html} from '../../types/html'; +import {html, safe} from '../../types/html'; import {v4} from 'uuid'; import {UUID} from 'io-ts-types'; @@ -23,7 +23,7 @@ const renderForm = (viewModel: ViewModel) => `, - pageTemplate('Create Area', viewModel.user) + pageTemplate(safe('Create Area'), viewModel.user) ); export const createForm: Form = { diff --git a/src/commands/equipment/add-form.ts b/src/commands/equipment/add-form.ts index 441743f9..478149dc 100644 --- a/src/commands/equipment/add-form.ts +++ b/src/commands/equipment/add-form.ts @@ -2,7 +2,7 @@ import {pipe} from 'fp-ts/lib/function'; import * as t from 'io-ts'; import * as E from 'fp-ts/Either'; import {pageTemplate} from '../../templates'; -import {html, sanitizeString} from '../../types/html'; +import {html, safe, sanitizeString} from '../../types/html'; import {DomainEvent, User} from '../../types'; import {v4} from 'uuid'; import {Form} from '../../types/form'; @@ -30,7 +30,7 @@ const renderForm = (viewModel: ViewModel) => `, - pageTemplate('Create Equipment', viewModel.user) + pageTemplate(safe('Create Equipment'), viewModel.user) ); const getAreaId = (input: unknown) => diff --git a/src/commands/equipment/register-training-sheet-form.ts b/src/commands/equipment/register-training-sheet-form.ts index 953cf00a..263c5688 100644 --- a/src/commands/equipment/register-training-sheet-form.ts +++ b/src/commands/equipment/register-training-sheet-form.ts @@ -1,6 +1,6 @@ import {pipe} from 'fp-ts/lib/function'; import * as E from 'fp-ts/Either'; -import {html, sanitizeString} from '../../types/html'; +import {html, safe, sanitizeString} from '../../types/html'; import {User} from '../../types'; import {Form} from '../../types/form'; import {pageTemplate} from '../../templates'; @@ -31,7 +31,7 @@ const renderForm = (viewModel: ViewModel) => `, - pageTemplate('Register training sheet', viewModel.user) + pageTemplate(safe('Register training sheet'), viewModel.user) ); const constructForm: Form['constructForm'] = diff --git a/src/commands/member-numbers/link-number-to-email-form.ts b/src/commands/member-numbers/link-number-to-email-form.ts index a1124d79..a0afa740 100644 --- a/src/commands/member-numbers/link-number-to-email-form.ts +++ b/src/commands/member-numbers/link-number-to-email-form.ts @@ -1,7 +1,7 @@ import {pipe} from 'fp-ts/lib/function'; import * as E from 'fp-ts/Either'; import {pageTemplate} from '../../templates'; -import {html} from '../../types/html'; +import {html, safe} from '../../types/html'; import {User} from '../../types'; import {Form} from '../../types/form'; @@ -24,7 +24,10 @@ const renderForm = (viewModel: ViewModel) => `, - pageTemplate('Link a member number to an e-mail address', viewModel.user) + pageTemplate( + safe('Link a member number to an e-mail address'), + viewModel.user + ) ); const constructForm: Form['constructForm'] = diff --git a/src/commands/members/edit-name-form.ts b/src/commands/members/edit-name-form.ts index 1ac84954..5c9c729a 100644 --- a/src/commands/members/edit-name-form.ts +++ b/src/commands/members/edit-name-form.ts @@ -1,7 +1,7 @@ import {flow, pipe} from 'fp-ts/lib/function'; import * as E from 'fp-ts/Either'; import {pageTemplate} from '../../templates'; -import {html} from '../../types/html'; +import {html, safe} from '../../types/html'; import {User} from '../../types'; import {Form} from '../../types/form'; import * as t from 'io-ts'; @@ -30,7 +30,7 @@ const renderForm = (viewModel: ViewModel) => `, - pageTemplate('Edit name', viewModel.user) + pageTemplate(safe('Edit name'), viewModel.user) ); const paramsCodec = t.strict({ diff --git a/src/commands/members/edit-pronouns-form.ts b/src/commands/members/edit-pronouns-form.ts index 4e308532..850f6e3b 100644 --- a/src/commands/members/edit-pronouns-form.ts +++ b/src/commands/members/edit-pronouns-form.ts @@ -1,7 +1,7 @@ import {flow, pipe} from 'fp-ts/lib/function'; import * as E from 'fp-ts/Either'; import {pageTemplate} from '../../templates'; -import {html} from '../../types/html'; +import {html, safe} from '../../types/html'; import {User} from '../../types'; import {Form} from '../../types/form'; import * as t from 'io-ts'; @@ -30,7 +30,7 @@ const renderForm = (viewModel: ViewModel) => `, - pageTemplate('Edit pronouns', viewModel.user) + pageTemplate(safe('Edit pronouns'), viewModel.user) ); const paramsCodec = t.strict({ diff --git a/src/commands/members/sign-owner-agreement-form.ts b/src/commands/members/sign-owner-agreement-form.ts index 721dd74d..62180dac 100644 --- a/src/commands/members/sign-owner-agreement-form.ts +++ b/src/commands/members/sign-owner-agreement-form.ts @@ -27,7 +27,7 @@ const renderForm = (viewModel: ViewModel) => `, - pageTemplate('Sign Owner Agreement', viewModel.user) + pageTemplate(safe('Sign Owner Agreement'), viewModel.user) ); const constructForm: Form['constructForm'] = diff --git a/src/commands/super-user/declare-form.ts b/src/commands/super-user/declare-form.ts index 5c4d7fe5..947eb8e5 100644 --- a/src/commands/super-user/declare-form.ts +++ b/src/commands/super-user/declare-form.ts @@ -1,7 +1,7 @@ import {pipe} from 'fp-ts/lib/function'; import * as E from 'fp-ts/Either'; import {pageTemplate} from '../../templates'; -import {html} from '../../types/html'; +import {html, safe} from '../../types/html'; import {MemberDetails, User} from '../../types'; import {Form} from '../../types/form'; import {memberInput} from '../../templates/member-input'; @@ -24,11 +24,15 @@ const render = (viewModel: ViewModel) => `, - pageTemplate('Declare super user', viewModel.user) + pageTemplate(safe('Declare super user'), viewModel.user) ); const renderForm = (viewModel: ViewModel) => - pipe(viewModel, render, pageTemplate('Declare super user', viewModel.user)); + pipe( + viewModel, + render, + pageTemplate(safe('Declare super user'), viewModel.user) + ); const constructForm: Form['constructForm'] = () => diff --git a/src/commands/super-user/revoke-form.ts b/src/commands/super-user/revoke-form.ts index d09c7d95..46c63b1c 100644 --- a/src/commands/super-user/revoke-form.ts +++ b/src/commands/super-user/revoke-form.ts @@ -3,7 +3,7 @@ import * as tt from 'io-ts-types'; import * as t from 'io-ts'; import * as E from 'fp-ts/Either'; import {pageTemplate} from '../../templates'; -import {html} from '../../types/html'; +import {html, safe} from '../../types/html'; import {User} from '../../types'; import {failureWithStatus} from '../../types/failure-with-status'; import {StatusCodes} from 'http-status-codes'; @@ -36,7 +36,7 @@ const renderForm = (viewModel: ViewModel) => `, - pageTemplate('Revoke super user', viewModel.user) + pageTemplate(safe('Revoke super user'), viewModel.user) ); const paramsCodec = t.strict({ diff --git a/src/commands/trainers/add-trainer-form.ts b/src/commands/trainers/add-trainer-form.ts index f4944ee3..349c84dd 100644 --- a/src/commands/trainers/add-trainer-form.ts +++ b/src/commands/trainers/add-trainer-form.ts @@ -2,7 +2,7 @@ import {pipe} from 'fp-ts/lib/function'; import * as t from 'io-ts'; import * as E from 'fp-ts/Either'; import {pageTemplate} from '../../templates'; -import {html, joinHtml, sanitizeString} from '../../types/html'; +import {html, joinHtml, safe, sanitizeString} from '../../types/html'; import {DomainEvent, Member, User} from '../../types'; import {Form} from '../../types/form'; import {formatValidationErrors} from 'io-ts-reporters'; @@ -73,7 +73,7 @@ const renderForm = (viewModel: ViewModel) => }).render(document.getElementById('wrapper')); `, - pageTemplate('Add Trainer', viewModel.user) + pageTemplate(safe('Add Trainer'), viewModel.user) ); const getEquipmentId = (input: unknown) => diff --git a/src/commands/trainers/mark-member-trained-form.ts b/src/commands/trainers/mark-member-trained-form.ts index b9569b98..2f16e4f4 100644 --- a/src/commands/trainers/mark-member-trained-form.ts +++ b/src/commands/trainers/mark-member-trained-form.ts @@ -1,6 +1,6 @@ import {pipe} from 'fp-ts/lib/function'; import * as E from 'fp-ts/Either'; -import {html, sanitizeString} from '../../types/html'; +import {html, safe, sanitizeString} from '../../types/html'; import {MemberDetails, User} from '../../types'; import {Form} from '../../types/form'; import {pageTemplate} from '../../templates'; @@ -36,7 +36,7 @@ const renderForm = (viewModel: ViewModel) => `, - pageTemplate('Member Training Complete', viewModel.user) + pageTemplate(safe('Member Training Complete'), viewModel.user) ); const constructForm: Form['constructForm'] = diff --git a/src/http/email-handler.ts b/src/http/email-handler.ts index 1a9514f8..1d7afff7 100644 --- a/src/http/email-handler.ts +++ b/src/http/email-handler.ts @@ -11,7 +11,7 @@ import {Actor} from '../types'; import {Request, Response} from 'express'; import * as E from 'fp-ts/Either'; import {formatValidationErrors} from 'io-ts-reporters'; -import {html, RenderedHtml, sanitizeString} from '../types/html'; +import {html, RenderedHtml, safe, sanitizeString} from '../types/html'; import {SendEmail} from '../commands'; import {Config} from '../configuration'; import {isolatedPageTemplate} from '../templates/page-template'; @@ -80,7 +80,7 @@ const emailPost = () => { res .status(200) - .send(isolatedPageTemplate('Email sent')(html`Email sent`)); + .send(isolatedPageTemplate(safe('Email sent'))(html`Email sent`)); } ) )(); diff --git a/src/queries/areas/index.ts b/src/queries/areas/index.ts index f611ea5a..ed8df226 100644 --- a/src/queries/areas/index.ts +++ b/src/queries/areas/index.ts @@ -10,5 +10,5 @@ export const areas: Query = deps => user => user, constructViewModel(deps), TE.map(render), - TE.map(html => HttpResponse.mk.Page({html})) + TE.map(rendered => HttpResponse.mk.Page({rendered})) ); diff --git a/src/queries/areas/render.ts b/src/queries/areas/render.ts index 80ea2574..c9606743 100644 --- a/src/queries/areas/render.ts +++ b/src/queries/areas/render.ts @@ -1,7 +1,14 @@ import {pipe} from 'fp-ts/lib/function'; -import {commaHtml, html, joinHtml, sanitizeString} from '../../types/html'; +import { + commaHtml, + html, + joinHtml, + safe, + sanitizeString, +} from '../../types/html'; import * as RA from 'fp-ts/ReadonlyArray'; import {ViewModel} from './view-model'; +import {pageTemplate} from '../../templates'; const renderAreas = (areas: ViewModel['areas']) => pipe( @@ -42,8 +49,12 @@ const addAreaCallToAction = html` Add area of responsibility `; -export const render = (viewModel: ViewModel) => html` -

    Areas of Makespace

    - ${viewModel.isSuperUser ? addAreaCallToAction : ''} - ${renderAreas(viewModel.areas)} -`; +export const render = (viewModel: ViewModel) => + pipe( + html` +

    Areas of Makespace

    + ${viewModel.isSuperUser ? addAreaCallToAction : ''} + ${renderAreas(viewModel.areas)} + `, + pageTemplate(safe('Areas'), viewModel.user) + ); diff --git a/src/queries/equipment/render.ts b/src/queries/equipment/render.ts index 4d25a336..20b721be 100644 --- a/src/queries/equipment/render.ts +++ b/src/queries/equipment/render.ts @@ -226,5 +226,5 @@ export const render = (viewModel: ViewModel) => ${trainersList(viewModel.equipment.trainers)} ${currentlyTrainedUsersTable(viewModel)} ${trainingQuizResults(viewModel)} `, - pageTemplate(viewModel.equipment.name, viewModel.user) + pageTemplate(sanitizeString(viewModel.equipment.name), viewModel.user) ); diff --git a/src/queries/landing/render.ts b/src/queries/landing/render.ts index e1a67cbe..61b25c0f 100644 --- a/src/queries/landing/render.ts +++ b/src/queries/landing/render.ts @@ -1,5 +1,5 @@ import {pipe} from 'fp-ts/lib/function'; -import {html, sanitizeString} from '../../types/html'; +import {html, safe, sanitizeString} from '../../types/html'; import {ViewModel} from './view-model'; import {pageTemplate} from '../../templates'; @@ -42,5 +42,5 @@ export const render = (viewModel: ViewModel) => ${viewModel.isSuperUser ? superUserNav : ''} `, - pageTemplate('Makespace Member Dashboard', viewModel.user) + pageTemplate(safe('Makespace Member Dashboard'), viewModel.user) ); diff --git a/src/queries/member/index.ts b/src/queries/member/index.ts index 632a015d..c088edfd 100644 --- a/src/queries/member/index.ts +++ b/src/queries/member/index.ts @@ -25,9 +25,9 @@ export const member: Query = deps => (user, params) => TE.fromEither, TE.chain(constructViewModel(deps, user)), TE.map(viewModel => render(viewModel)), - TE.map(html => + TE.map(rendered => HttpResponse.mk.Page({ - html, + rendered, }) ) ); diff --git a/src/templates/oops.ts b/src/templates/oops.ts index 21c6012a..b9cf6fe5 100644 --- a/src/templates/oops.ts +++ b/src/templates/oops.ts @@ -1,5 +1,5 @@ import {pipe} from 'fp-ts/lib/function'; -import {html, HtmlSubstitution} from '../types/html'; +import {html, HtmlSubstitution, safe} from '../types/html'; import {isolatedPageTemplate} from './page-template'; export const oopsPage = (message: HtmlSubstitution) => @@ -12,5 +12,5 @@ export const oopsPage = (message: HtmlSubstitution) => group.

    `, - isolatedPageTemplate('Oops') + isolatedPageTemplate(safe('Oops')) ); From 3a0ef26698f4130da6e394b4ec08689187e9f1fb Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 18:18:37 +0100 Subject: [PATCH 40/45] Simplify where we can --- src/authentication/handlers.ts | 4 +--- src/queries/equipment/render.ts | 16 ++++++++-------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/authentication/handlers.ts b/src/authentication/handlers.ts index e3afc210..6b40641d 100644 --- a/src/authentication/handlers.ts +++ b/src/authentication/handlers.ts @@ -46,9 +46,7 @@ export const auth = (req: Request, res: Response) => { E.mapLeft(() => "You entered something that isn't a valid email address"), E.matchW( msg => - res - .status(StatusCodes.BAD_REQUEST) - .send(oopsPage(html`${sanitizeString(msg)}`)), + res.status(StatusCodes.BAD_REQUEST).send(oopsPage(sanitizeString(msg))), email => { publish('send-log-in-link', email); res.status(StatusCodes.ACCEPTED).send(checkYourMailPage(email)); diff --git a/src/queries/equipment/render.ts b/src/queries/equipment/render.ts index 20b721be..3d2b580a 100644 --- a/src/queries/equipment/render.ts +++ b/src/queries/equipment/render.ts @@ -2,7 +2,13 @@ import {pipe} from 'fp-ts/lib/function'; import {pageTemplate} from '../../templates'; import {displayDate} from '../../templates/display-date'; import {renderMemberNumber} from '../../templates/member-number'; -import {html, joinHtml, optionalSafe, sanitizeString} from '../../types/html'; +import { + commaHtml, + html, + joinHtml, + optionalSafe, + sanitizeString, +} from '../../types/html'; import { QuizResultUnknownMemberViewModel, QuizResultViewModel, @@ -10,7 +16,6 @@ import { } from './view-model'; import * as O from 'fp-ts/Option'; import * as RA from 'fp-ts/ReadonlyArray'; -import {UUID} from 'io-ts-types'; const trainersList = (trainers: ViewModel['equipment']['trainers']) => pipe( @@ -82,12 +87,7 @@ const currentlyTrainedUsersTable = (viewModel: ViewModel) => ); // Hidden by default behind a visibility toggle. -const renderOtherAttempts = (otherAttempts: ReadonlyArray) => - pipe( - otherAttempts, - RA.map(v => html`${v}`), - joinHtml - ); +const renderOtherAttempts = commaHtml; const waitingForTrainingRow = (quiz: QuizResultViewModel) => pipe( From 69d7cb5e04bfaa27cba78b32d2bbb140c449f0be Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 20:50:24 +0100 Subject: [PATCH 41/45] Update src/authentication/log-in-page.ts --- src/authentication/log-in-page.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/authentication/log-in-page.ts b/src/authentication/log-in-page.ts index a6977ec6..ccf531e8 100644 --- a/src/authentication/log-in-page.ts +++ b/src/authentication/log-in-page.ts @@ -12,5 +12,5 @@ export const logInPage = pipe( `, - isolatedPageTemplate(safe('Login')) + isolatedPageTemplate(safe('MakeSpace Members App')) ); From 6fcd401a39b0ab104363b961b9050ed2326c0663 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 20:51:55 +0100 Subject: [PATCH 42/45] Apply suggestions from code review --- src/commands/equipment/get-equipment-id-from-form.ts | 4 ++-- src/commands/super-user/revoke-form.ts | 2 +- src/commands/trainers/add-trainer-form.ts | 2 +- src/queries/equipment/render.ts | 4 ++-- src/queries/landing/render.ts | 7 ++++++- src/queries/member/render.ts | 2 +- src/queries/super-users/render.ts | 2 +- src/types/html.ts | 1 + 8 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/commands/equipment/get-equipment-id-from-form.ts b/src/commands/equipment/get-equipment-id-from-form.ts index b5336f6e..ac96b24b 100644 --- a/src/commands/equipment/get-equipment-id-from-form.ts +++ b/src/commands/equipment/get-equipment-id-from-form.ts @@ -1,13 +1,13 @@ import {pipe} from 'fp-ts/lib/function'; import * as t from 'io-ts'; -import * as tt from 'io-ts-types'; +import {UUID} from 'io-ts-types'; import * as E from 'fp-ts/Either'; import {formatValidationErrors} from 'io-ts-reporters'; import {failureWithStatus} from '../../types/failure-with-status'; import {StatusCodes} from 'http-status-codes'; const getEquipmentIdCodec = t.strict({ - equipmentId: tt.UUID, + equipmentId: UUID, }); export const getEquipmentIdFromForm = (input: unknown) => diff --git a/src/commands/super-user/revoke-form.ts b/src/commands/super-user/revoke-form.ts index 46c63b1c..e06ac4b1 100644 --- a/src/commands/super-user/revoke-form.ts +++ b/src/commands/super-user/revoke-form.ts @@ -25,7 +25,7 @@ const renderForm = (viewModel: ViewModel) =>

    Member number
    -
    ${viewModel.toBeRevoked}
    +
    ${renderMemberNumber(viewModel.toBeRevoked)}
    E-Mail Member Number - + Action diff --git a/src/queries/equipment/render.ts b/src/queries/equipment/render.ts index 3d2b580a..90f109f3 100644 --- a/src/queries/equipment/render.ts +++ b/src/queries/equipment/render.ts @@ -95,7 +95,7 @@ const waitingForTrainingRow = (quiz: QuizResultViewModel) => renderOtherAttempts, otherAttempts => html` - ${sanitizeString(quiz.id)} + ${quiz.id} ${displayDate(quiz.timestamp)} ${renderMemberNumber(quiz.memberNumber)} ${quiz.score} / ${quiz.maxScore} (${quiz.percentage}%) @@ -175,7 +175,7 @@ const failedKnownQuizRow = (knownQuiz: QuizResultViewModel) => renderOtherAttempts, otherAttempts => html` - ${sanitizeString(knownQuiz.id)} + ${knownQuiz.id} ${displayDate(knownQuiz.timestamp)} ${renderMemberNumber(knownQuiz.memberNumber)} diff --git a/src/queries/landing/render.ts b/src/queries/landing/render.ts index 61b25c0f..21863270 100644 --- a/src/queries/landing/render.ts +++ b/src/queries/landing/render.ts @@ -8,7 +8,7 @@ const renderMemberDetails = (user: ViewModel['user']) => html`
    Email
    ${sanitizeString(user.emailAddress)}
    Member Number
    -
    ${user.memberNumber}
    +
    ${renderMemberNumber(user.memberNumber)}
    `; @@ -20,6 +20,11 @@ const superUserNav = html`
  • Link a member number to an e-mail address
  • +
  • + See member number imports that need fixing +
  • Add area of responsibility
  • diff --git a/src/queries/member/render.ts b/src/queries/member/render.ts index 2cdcf382..bd91c75a 100644 --- a/src/queries/member/render.ts +++ b/src/queries/member/render.ts @@ -39,7 +39,7 @@ export const render = (viewModel: ViewModel) => Member number - ${viewModel.member.memberNumber} + ${renderMemberNumber(viewModel.member.memberNumber)} Email diff --git a/src/queries/super-users/render.ts b/src/queries/super-users/render.ts index c4f0ed6c..5de057e0 100644 --- a/src/queries/super-users/render.ts +++ b/src/queries/super-users/render.ts @@ -11,7 +11,7 @@ const renderSuperUsers = (superUsers: ViewModel['superUsers']) => RA.map( user => html` - ${user.memberNumber} + ${renderMemberNumber(user.memberNumber)} ${displayDate(user.since)} diff --git a/src/types/html.ts b/src/types/html.ts index 6253a343..c94ad457 100644 --- a/src/types/html.ts +++ b/src/types/html.ts @@ -9,6 +9,7 @@ type SanitizedString = string & { readonly SanitizedString: unique symbol; }; +// Export required as we want to re-export the output of stuff like `export const loginLink = safe('/login')` export type Safe = string & {readonly Safe: unique symbol}; export const sanitizeString = (input: string): SanitizedString => From 9d19a28ab37359b8ef62799d7bd9fac2de52e0c5 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 20:58:54 +0100 Subject: [PATCH 43/45] Use shared gridjs helper for add trainer form --- src/commands/trainers/add-trainer-form.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/commands/trainers/add-trainer-form.ts b/src/commands/trainers/add-trainer-form.ts index 0017dec1..cb0a1047 100644 --- a/src/commands/trainers/add-trainer-form.ts +++ b/src/commands/trainers/add-trainer-form.ts @@ -49,7 +49,7 @@ const renderForm = (viewModel: ViewModel) => tableRows => html`

    Add a trainer

    - +
    @@ -61,17 +61,6 @@ const renderForm = (viewModel: ViewModel) => ${tableRows}
    E-Mail
    - `, pageTemplate(safe('Add Trainer'), viewModel.user) ); From bbf9b2c45be5ccbb3403236ef966aeef9b344359 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 21:01:50 +0100 Subject: [PATCH 44/45] Things spotted during self review --- src/queries/areas/render.ts | 3 ++- src/templates/avatar.ts | 23 +++++++++++++---------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/queries/areas/render.ts b/src/queries/areas/render.ts index c9606743..63824562 100644 --- a/src/queries/areas/render.ts +++ b/src/queries/areas/render.ts @@ -9,6 +9,7 @@ import { import * as RA from 'fp-ts/ReadonlyArray'; import {ViewModel} from './view-model'; import {pageTemplate} from '../../templates'; +import {renderMemberNumber} from '../../templates/member-number'; const renderAreas = (areas: ViewModel['areas']) => pipe( @@ -19,7 +20,7 @@ const renderAreas = (areas: ViewModel['areas']) =>
    ${sanitizeString(area.name)} - ${commaHtml(area.owners)} + ${commaHtml(area.owners.map(renderMemberNumber))} Add owner diff --git a/src/templates/avatar.ts b/src/templates/avatar.ts index 048c57fe..28fdc53e 100644 --- a/src/templates/avatar.ts +++ b/src/templates/avatar.ts @@ -1,16 +1,16 @@ import {createHash} from 'crypto'; -import {html, safe} from '../types/html'; +import {html, Safe, safe} from '../types/html'; function getGravatarUrl(email: string, size: number = 160) { const trimmedEmail = email.trim().toLowerCase(); const hash = createHash('sha256').update(trimmedEmail).digest('hex'); - return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=identicon`; + return safe(`https://www.gravatar.com/avatar/${hash}?s=${size}&d=identicon`); } type GravatarViewModel = { - url1x: string; - url2x: string; - url4x: string; + url1x: Safe; + url2x: Safe; + url4x: Safe; memberNumber: number; }; @@ -29,11 +29,11 @@ const gravatar = /> `; -const avatarThumbnail = gravatar(40, 40); -const avatarProfile = gravatar(320, 320); - export const getGravatarThumbnail = (email: string, memberNumber: number) => - avatarThumbnail({ + gravatar( + 40, + 40 + )({ url1x: getGravatarUrl(email, 40), url2x: getGravatarUrl(email, 80), url4x: getGravatarUrl(email, 160), @@ -41,7 +41,10 @@ export const getGravatarThumbnail = (email: string, memberNumber: number) => }); export const getGravatarProfile = (email: string, memberNumber: number) => - avatarProfile({ + gravatar( + 320, + 320 + )({ url1x: getGravatarUrl(email, 320), url2x: getGravatarUrl(email, 640), url4x: getGravatarUrl(email, 1280), From 1d36732ce3a4e1168098872cecf332ae8a4add88 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 20 Jul 2024 21:05:12 +0100 Subject: [PATCH 45/45] Missing renderMemberNumber imports --- src/commands/super-user/revoke-form.ts | 1 + src/queries/landing/render.ts | 1 + src/queries/member/render.ts | 1 + src/queries/super-users/render.ts | 1 + 4 files changed, 4 insertions(+) diff --git a/src/commands/super-user/revoke-form.ts b/src/commands/super-user/revoke-form.ts index e06ac4b1..96cd0bab 100644 --- a/src/commands/super-user/revoke-form.ts +++ b/src/commands/super-user/revoke-form.ts @@ -9,6 +9,7 @@ import {failureWithStatus} from '../../types/failure-with-status'; import {StatusCodes} from 'http-status-codes'; import {formatValidationErrors} from 'io-ts-reporters'; import {Form} from '../../types/form'; +import {renderMemberNumber} from '../../templates/member-number'; type ViewModel = { user: User; diff --git a/src/queries/landing/render.ts b/src/queries/landing/render.ts index 21863270..397e8263 100644 --- a/src/queries/landing/render.ts +++ b/src/queries/landing/render.ts @@ -2,6 +2,7 @@ import {pipe} from 'fp-ts/lib/function'; import {html, safe, sanitizeString} from '../../types/html'; import {ViewModel} from './view-model'; import {pageTemplate} from '../../templates'; +import {renderMemberNumber} from '../../templates/member-number'; const renderMemberDetails = (user: ViewModel['user']) => html`
    diff --git a/src/queries/member/render.ts b/src/queries/member/render.ts index bd91c75a..6379db82 100644 --- a/src/queries/member/render.ts +++ b/src/queries/member/render.ts @@ -3,6 +3,7 @@ import {getGravatarProfile, getGravatarThumbnail} from '../../templates/avatar'; import {Html, html, optionalSafe, safe, sanitizeString} from '../../types/html'; import {ViewModel} from './view-model'; import {pageTemplate} from '../../templates'; +import {renderMemberNumber} from '../../templates/member-number'; const ownPageBanner = html`

    This is your profile!

    `; diff --git a/src/queries/super-users/render.ts b/src/queries/super-users/render.ts index 5de057e0..4b3b5e60 100644 --- a/src/queries/super-users/render.ts +++ b/src/queries/super-users/render.ts @@ -4,6 +4,7 @@ import * as RA from 'fp-ts/ReadonlyArray'; import {ViewModel} from './view-model'; import {displayDate} from '../../templates/display-date'; import {pageTemplate} from '../../templates'; +import {renderMemberNumber} from '../../templates/member-number'; const renderSuperUsers = (superUsers: ViewModel['superUsers']) => pipe(