Skip to content

Commit f830f29

Browse files
authored
Allow admins to edit other users emails (#181)
* Add member email management overview for super users * Reuse /me email renderer on member profiles * Use a shared member emails render * Changes to make the forms make sense when an admin is editing another account
1 parent 3f4bdf6 commit f830f29

File tree

6 files changed

+290
-124
lines changed

6 files changed

+290
-124
lines changed

src/commands/members/change-primary-email-form.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const renderForm = (viewModel: ViewModel) =>
1919
pipe(
2020
html`
2121
<h1>Change primary email</h1>
22-
<form action="?next=/me" method="post">
22+
<form action="?next=/member/${viewModel.memberNumber}" method="post">
2323
<label for="email">Email address</label>
2424
<input
2525
type="email"

src/commands/members/send-email-verification-form.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,24 @@ type ViewModel = {
1313
user: User;
1414
memberNumber: number;
1515
emailAddress: string;
16+
isSelf: boolean;
1617
};
1718

1819
const renderForm = (viewModel: ViewModel) =>
1920
pipe(
20-
html`
21+
viewModel.isSelf
22+
? html`<p>
23+
In order to finish adding an email to your profile it needs to be verified. Click the button below
24+
to send an email with a verification link to '${sanitizeString(viewModel.emailAddress)}'
25+
</p>`
26+
: html`<p>
27+
In order to finish adding an email to this profile (member number ${viewModel.memberNumber}) it needs to be verified.
28+
Click the button below to send an email with a verification link to '${sanitizeString(viewModel.emailAddress)}'
29+
</p>`,
30+
(description) => html`
2131
<h1>Send email verification</h1>
22-
<p>
23-
In order to finish adding an email to your profile it needs to be verified. Click the button below
24-
to send an email with a verification link to '${sanitizeString(viewModel.emailAddress)}'
25-
</p>
26-
<form action="?next=/me" method="post">
32+
${description}
33+
<form action="?next=/member/${viewModel.memberNumber}" method="post">
2734
<input
2835
type="hidden"
2936
name="email"
@@ -66,6 +73,7 @@ const constructForm: Form<ViewModel>['constructForm'] =
6673
user,
6774
memberNumber: params.member,
6875
emailAddress: params.email,
76+
isSelf: user.memberNumber === params.member
6977
}))
7078
);
7179

src/queries/me/render.ts

Lines changed: 8 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {getGravatarThumbnail} from '../../templates/avatar';
2-
import {Html, html, joinHtml, sanitizeOption, sanitizeString} from '../../types/html';
2+
import {html, sanitizeOption} from '../../types/html';
33
import {ViewModel} from './view-model';
44
import {renderMemberNumber} from '../../templates/member-number';
55
import {renderOwnerAgreementStatus} from '../shared-render/owner-agreement';
@@ -10,10 +10,7 @@ import {
1010
import {howToGetTrained} from '../shared-render/training-status';
1111
import {ownerResources} from './owner-resources';
1212
import { renderTrainingMatrix } from '../training-matrix/render';
13-
import * as O from 'fp-ts/Option';
14-
import { MemberEmail } from '../../read-models/shared-state/return-types';
15-
import { EmailAddress } from '../../types';
16-
import { SEND_EMAIL_VERIFICATION_COOLDOWN_MS } from '../../commands/members/email-state';
13+
import {renderMemberEmails} from '../shared-render/member-emails';
1714

1815
const editFormOfAddress = (viewModel: ViewModel) => html`
1916
<a
@@ -23,104 +20,15 @@ const editFormOfAddress = (viewModel: ViewModel) => html`
2320
</a>
2421
`;
2522

26-
const sendVerifyEmail = (memberNumber: number, email: MemberEmail) => {
27-
if (
28-
O.isSome(email.verificationLastSent) && (
29-
(Date.now() - email.verificationLastSent.value.getTime()) < SEND_EMAIL_VERIFICATION_COOLDOWN_MS
30-
)
31-
) {
32-
return html`Verification Email Sent At ${sanitizeString(email.verificationLastSent.value.toLocaleTimeString())}!`
33-
}
34-
return html`
35-
<a
36-
href="/members/send-email-verification?email=${sanitizeString(email.emailAddress)}&member=${memberNumber}"
37-
>
38-
Send Verification Email
39-
</a>
40-
`;
41-
}
42-
43-
const setPrimaryEmail = (email: EmailAddress, memberNumber: number) => html`
44-
<a
45-
href="/members/change-primary-email?email=${sanitizeString(email)}&member=${memberNumber}"
46-
>
47-
Make Primary Email
48-
</a>
49-
`;
50-
51-
const addEmail = (memberNumber: number) => html`
52-
<a
53-
href="/members/add-email?member=${memberNumber}"
54-
>
55-
Add New Email
56-
</a>
57-
`;
58-
5923
const editAvatar = () =>
6024
html`<a href="https://gravatar.com/profile">Edit via Gravatar</a>`;
6125

62-
const sortMemberEmailByVerifiedThenAddedDate = (a: MemberEmail, b: MemberEmail): 1 | -1 | 0 => {
63-
if (O.isSome(a.verifiedAt) && O.isSome(b.verifiedAt)) {
64-
const aVerifiedTimestamp = a.verifiedAt.value.getTime();
65-
const bVerifiedTimestamp = b.verifiedAt.value.getTime();
66-
if (aVerifiedTimestamp > bVerifiedTimestamp) {
67-
return 1;
68-
}
69-
if (aVerifiedTimestamp === bVerifiedTimestamp) {
70-
return 0;
71-
}
72-
return -1;
73-
}
74-
if (O.isSome(a.verifiedAt) && O.isNone(b.verifiedAt)) {
75-
return 1;
76-
}
77-
if (O.isSome(b.verifiedAt) && O.isNone(a.verifiedAt)) {
78-
return -1;
79-
}
80-
const aAddedTimestamp = a.addedAt.getTime();
81-
const bAddedTimestamp = b.addedAt.getTime();
82-
if (aAddedTimestamp > bAddedTimestamp) {
83-
return 1;
84-
}
85-
if (aAddedTimestamp === bAddedTimestamp) {
86-
return 0;
87-
}
88-
return -1;
89-
};
90-
91-
const renderEmailAddresses = (viewModel: ViewModel) => {
92-
const emails = viewModel.member.emails.filter(
93-
email => email.emailAddress != viewModel.member.primaryEmailAddress
94-
).toSorted(sortMemberEmailByVerifiedThenAddedDate);
95-
96-
const renderEmailTableRow = (email: MemberEmail): Html => {
97-
return html`
98-
<tr>
99-
<td></td>
100-
<td>${sanitizeString(email.emailAddress)}${O.isSome(email.verifiedAt) ? html`✅` : html``}</td>
101-
<td>${
102-
O.isSome(email.verifiedAt)
103-
? setPrimaryEmail(email.emailAddress, viewModel.member.memberNumber)
104-
: sendVerifyEmail(viewModel.member.memberNumber, email)
105-
}</td>
106-
</tr>
107-
`;
108-
};
109-
110-
return html`<table>
111-
<tr>
112-
<td>Primary</td>
113-
<td>${sanitizeString(viewModel.member.primaryEmailAddress)}</td>
114-
<td></td>
115-
</tr>
116-
${joinHtml(emails.toSorted(sortMemberEmailByVerifiedThenAddedDate).map(renderEmailTableRow))}
117-
<tr>
118-
<td colspan="3">
119-
${addEmail(viewModel.member.memberNumber)}
120-
</td>
121-
</tr>
122-
</table>`;
123-
};
26+
const renderEmailAddresses = (viewModel: ViewModel) =>
27+
renderMemberEmails(
28+
viewModel.member.memberNumber,
29+
viewModel.member.emails,
30+
viewModel.member.primaryEmailAddress,
31+
);
12432

12533
const renderMemberDetails = (viewModel: ViewModel) => html`
12634
<table>

src/queries/member/render.ts

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import {getGravatarProfile, getGravatarThumbnail} from '../../templates/avatar';
2-
import {Html, html, sanitizeOption, sanitizeString} from '../../types/html';
2+
import {Html, html, sanitizeOption} from '../../types/html';
33
import {ViewModel} from './view-model';
44
import {
55
renderMemberNumber,
66
renderMemberNumbers,
77
} from '../../templates/member-number';
88
import {memberStatusTag} from '../../templates/member-status';
99
import {otherMemberNumbersTooltip} from '../shared-render/other-member-numbers-tooltip';
10-
import { renderTrainingMatrix } from '../training-matrix/render';
11-
import { renderOwnerAgreementStatus } from '../shared-render/owner-agreement';
10+
import {renderTrainingMatrix} from '../training-matrix/render';
11+
import {renderOwnerAgreementStatus} from '../shared-render/owner-agreement';
12+
import {renderMemberEmails} from '../shared-render/member-emails';
1213

1314
const ownPageBanner = html`<h1>This is your profile!</h1>`;
1415

@@ -27,6 +28,13 @@ const editFormOfAddress = (viewModel: ViewModel) =>
2728
const editAvatar = () =>
2829
html`<a href="https://gravatar.com/profile">Edit via Gravatar</a>`;
2930

31+
const renderEmails = (viewModel: ViewModel) =>
32+
renderMemberEmails(
33+
viewModel.member.memberNumber,
34+
viewModel.member.emails,
35+
viewModel.member.primaryEmailAddress,
36+
);
37+
3038
const ifSelf = (viewModel: ViewModel, fragment: Html) =>
3139
viewModel.isSelf ? fragment : '';
3240

@@ -51,10 +59,14 @@ export const render = (viewModel: ViewModel) => html`
5159
<th scope="row">Other Member Numbers ${otherMemberNumbersTooltip}</th>
5260
<td>${renderMemberNumbers(viewModel.member.pastMemberNumbers)}</td>
5361
</tr>
54-
<tr>
55-
<th scope="row">Primary email</th>
56-
<td>${sanitizeString(viewModel.member.primaryEmailAddress)}</td>
57-
</tr>
62+
${
63+
viewModel.isSelf || viewModel.isSuperUser ? html`
64+
<tr>
65+
<th scope="row">Email addresses</th>
66+
<td>${renderEmails(viewModel)}</td>
67+
</tr>
68+
` : html``
69+
}
5870
<tr>
5971
<th scope="row">
6072
<p>Name</p>
@@ -89,16 +101,17 @@ export const render = (viewModel: ViewModel) => html`
89101
${ifSelf(viewModel, editAvatar())}
90102
</td>
91103
</tr>
92-
${viewModel.isSuperUser ? html`<tr>
93-
<th scope="row">Owner agreement</th>
94-
<td>
95-
${renderOwnerAgreementStatus(
96-
viewModel.member.agreementSigned,
97-
true
98-
)}
99-
</td>
100-
</tr>`
101-
: html``}
104+
${viewModel.isSuperUser
105+
? html`<tr>
106+
<th scope="row">Owner agreement</th>
107+
<td>
108+
${renderOwnerAgreementStatus(
109+
viewModel.member.agreementSigned,
110+
true
111+
)}
112+
</td>
113+
</tr>`
114+
: html``}
102115
${renderTrainingMatrix(viewModel.trainingMatrix)}
103116
</tbody>
104117
</table>
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import * as O from 'fp-ts/Option';
2+
import {MemberEmail} from '../../read-models/shared-state/return-types';
3+
import {Html, html, joinHtml, sanitizeString} from '../../types/html';
4+
import { EmailAddress } from '../../types/email-address';
5+
import { SEND_EMAIL_VERIFICATION_COOLDOWN_MS } from '../../commands/members/email-state';
6+
7+
const sortMemberEmailByVerifiedThenAddedDate = (
8+
a: MemberEmail,
9+
b: MemberEmail
10+
): 1 | -1 | 0 => {
11+
if (O.isSome(a.verifiedAt) && O.isSome(b.verifiedAt)) {
12+
const aVerifiedTimestamp = a.verifiedAt.value.getTime();
13+
const bVerifiedTimestamp = b.verifiedAt.value.getTime();
14+
if (aVerifiedTimestamp > bVerifiedTimestamp) {
15+
return 1;
16+
}
17+
if (aVerifiedTimestamp === bVerifiedTimestamp) {
18+
return 0;
19+
}
20+
return -1;
21+
}
22+
if (O.isSome(a.verifiedAt) && O.isNone(b.verifiedAt)) {
23+
return 1;
24+
}
25+
if (O.isSome(b.verifiedAt) && O.isNone(a.verifiedAt)) {
26+
return -1;
27+
}
28+
const aAddedTimestamp = a.addedAt.getTime();
29+
const bAddedTimestamp = b.addedAt.getTime();
30+
if (aAddedTimestamp > bAddedTimestamp) {
31+
return 1;
32+
}
33+
if (aAddedTimestamp === bAddedTimestamp) {
34+
return 0;
35+
}
36+
return -1;
37+
};
38+
39+
const sendVerifyEmail = (memberNumber: number, email: MemberEmail) => {
40+
if (
41+
O.isSome(email.verificationLastSent) && (
42+
(Date.now() - email.verificationLastSent.value.getTime()) < SEND_EMAIL_VERIFICATION_COOLDOWN_MS
43+
)
44+
) {
45+
return html`Verification Email Sent At ${sanitizeString(email.verificationLastSent.value.toLocaleTimeString())}!`
46+
}
47+
return html`
48+
<a
49+
href="/members/send-email-verification?email=${sanitizeString(email.emailAddress)}&member=${memberNumber}"
50+
>
51+
Send Verification Email
52+
</a>
53+
`;
54+
}
55+
56+
const setPrimaryEmail = (email: EmailAddress, memberNumber: number) => html`
57+
<a
58+
href="/members/change-primary-email?email=${sanitizeString(email)}&member=${memberNumber}"
59+
>
60+
Make Primary Email
61+
</a>
62+
`;
63+
64+
const addEmail = (memberNumber: number) => html`
65+
<a
66+
href="/members/add-email?member=${memberNumber}"
67+
>
68+
Add New Email
69+
</a>
70+
`;
71+
72+
export const renderMemberEmails = (
73+
memberNumber: number,
74+
emails: ReadonlyArray<MemberEmail>,
75+
primaryEmailAddress: EmailAddress
76+
): Html => {
77+
const secondaryEmails = emails
78+
.filter(email => email.emailAddress !== primaryEmailAddress)
79+
.toSorted(sortMemberEmailByVerifiedThenAddedDate);
80+
81+
const renderEmailTableRow = (email: MemberEmail): Html => html`
82+
<tr>
83+
<td></td>
84+
<td>
85+
${sanitizeString(email.emailAddress)}
86+
${O.isSome(email.verifiedAt) ? html`✅` : html``}
87+
</td>
88+
<td>${
89+
O.isSome(email.verifiedAt)
90+
? setPrimaryEmail(email.emailAddress, memberNumber)
91+
: sendVerifyEmail(memberNumber, email)
92+
}</td>
93+
</tr>
94+
`;
95+
96+
return html`
97+
<table>
98+
<tr>
99+
<td>Primary</td>
100+
<td>${sanitizeString(primaryEmailAddress)}</td>
101+
<td></td>
102+
</tr>
103+
${joinHtml(secondaryEmails.map(renderEmailTableRow))}
104+
<tr>
105+
<td colspan="3">${addEmail(memberNumber)}</td>
106+
</tr>
107+
</table>
108+
`;
109+
};

0 commit comments

Comments
 (0)