Skip to content

Commit 61c1c54

Browse files
committed
Add member email management overview for super users
1 parent 3f4bdf6 commit 61c1c54

File tree

2 files changed

+183
-15
lines changed

2 files changed

+183
-15
lines changed

src/queries/member/render.ts

Lines changed: 86 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1+
import * as O from 'fp-ts/Option';
12
import {getGravatarProfile, getGravatarThumbnail} from '../../templates/avatar';
2-
import {Html, html, sanitizeOption, sanitizeString} from '../../types/html';
3+
import {Html, html, joinHtml, sanitizeOption, sanitizeString} from '../../types/html';
34
import {ViewModel} from './view-model';
45
import {
56
renderMemberNumber,
67
renderMemberNumbers,
78
} from '../../templates/member-number';
89
import {memberStatusTag} from '../../templates/member-status';
910
import {otherMemberNumbersTooltip} from '../shared-render/other-member-numbers-tooltip';
10-
import { renderTrainingMatrix } from '../training-matrix/render';
11-
import { renderOwnerAgreementStatus } from '../shared-render/owner-agreement';
11+
import {renderTrainingMatrix} from '../training-matrix/render';
12+
import {renderOwnerAgreementStatus} from '../shared-render/owner-agreement';
13+
import {MemberEmail} from '../../read-models/shared-state/return-types';
1214

1315
const ownPageBanner = html`<h1>This is your profile!</h1>`;
1416

@@ -27,6 +29,74 @@ const editFormOfAddress = (viewModel: ViewModel) =>
2729
const editAvatar = () =>
2830
html`<a href="https://gravatar.com/profile">Edit via Gravatar</a>`;
2931

32+
const addEmail = (memberNumber: number) => html`
33+
<a href="/members/add-email?member=${memberNumber}">
34+
Add email
35+
</a>
36+
`;
37+
38+
const renderEmailStatus = (
39+
email: MemberEmail,
40+
primaryEmailAddress: string
41+
): string => {
42+
if (email.emailAddress === primaryEmailAddress) {
43+
return 'Primary';
44+
}
45+
if (O.isSome(email.verifiedAt)) {
46+
return 'Verified';
47+
}
48+
return 'Unverified';
49+
};
50+
51+
const renderEmails = (viewModel: ViewModel): Html => {
52+
const emails = [...viewModel.member.emails].sort((a, b) => {
53+
const aIsPrimary = a.emailAddress === viewModel.member.primaryEmailAddress;
54+
const bIsPrimary = b.emailAddress === viewModel.member.primaryEmailAddress;
55+
if (aIsPrimary && !bIsPrimary) {
56+
return -1;
57+
}
58+
if (!aIsPrimary && bIsPrimary) {
59+
return 1;
60+
}
61+
return a.emailAddress.localeCompare(b.emailAddress);
62+
});
63+
64+
return html`
65+
<div>
66+
<table>
67+
<thead>
68+
<tr>
69+
<th scope="col">Email</th>
70+
<th scope="col">Status</th>
71+
</tr>
72+
</thead>
73+
<tbody>
74+
${joinHtml(
75+
emails.map(
76+
email => html`
77+
<tr>
78+
<td>${sanitizeString(email.emailAddress)}</td>
79+
<td>
80+
${sanitizeString(
81+
renderEmailStatus(
82+
email,
83+
viewModel.member.primaryEmailAddress
84+
)
85+
)}
86+
</td>
87+
</tr>
88+
`
89+
)
90+
)}
91+
</tbody>
92+
</table>
93+
${viewModel.isSuperUser
94+
? html`<p>${addEmail(viewModel.member.memberNumber)}</p>`
95+
: html``}
96+
</div>
97+
`;
98+
};
99+
30100
const ifSelf = (viewModel: ViewModel, fragment: Html) =>
31101
viewModel.isSelf ? fragment : '';
32102

@@ -52,8 +122,8 @@ export const render = (viewModel: ViewModel) => html`
52122
<td>${renderMemberNumbers(viewModel.member.pastMemberNumbers)}</td>
53123
</tr>
54124
<tr>
55-
<th scope="row">Primary email</th>
56-
<td>${sanitizeString(viewModel.member.primaryEmailAddress)}</td>
125+
<th scope="row">Email addresses</th>
126+
<td>${renderEmails(viewModel)}</td>
57127
</tr>
58128
<tr>
59129
<th scope="row">
@@ -89,16 +159,17 @@ export const render = (viewModel: ViewModel) => html`
89159
${ifSelf(viewModel, editAvatar())}
90160
</td>
91161
</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``}
162+
${viewModel.isSuperUser
163+
? html`<tr>
164+
<th scope="row">Owner agreement</th>
165+
<td>
166+
${renderOwnerAgreementStatus(
167+
viewModel.member.agreementSigned,
168+
true
169+
)}
170+
</td>
171+
</tr>`
172+
: html``}
102173
${renderTrainingMatrix(viewModel.trainingMatrix)}
103174
</tbody>
104175
</table>
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
5+
import * as O from 'fp-ts/Option';
6+
import {render} from '../../../src/queries/member/render';
7+
import {ViewModel} from '../../../src/queries/member/view-model';
8+
import {EmailAddress, GravatarHash} from '../../../src/types';
9+
10+
const primaryEmail = 'primary@example.com' as EmailAddress;
11+
const unverifiedEmail = 'extra@example.com' as EmailAddress;
12+
const verifiedSecondaryEmail = 'verified@example.com' as EmailAddress;
13+
14+
const buildViewModel = (isSuperUser: boolean): ViewModel => ({
15+
member: {
16+
memberNumber: 123,
17+
pastMemberNumbers: [122],
18+
primaryEmailAddress: primaryEmail,
19+
emails: [
20+
{
21+
emailAddress: primaryEmail,
22+
verifiedAt: O.some(new Date('2025-01-01T00:00:00.000Z')),
23+
addedAt: new Date('2025-01-01T00:00:00.000Z'),
24+
verificationLastSent: O.none,
25+
},
26+
{
27+
emailAddress: unverifiedEmail,
28+
verifiedAt: O.none,
29+
addedAt: new Date('2025-01-02T00:00:00.000Z'),
30+
verificationLastSent: O.none,
31+
},
32+
{
33+
emailAddress: verifiedSecondaryEmail,
34+
verifiedAt: O.some(new Date('2025-01-03T00:00:00.000Z')),
35+
addedAt: new Date('2025-01-03T00:00:00.000Z'),
36+
verificationLastSent: O.none,
37+
},
38+
],
39+
name: O.none,
40+
formOfAddress: O.none,
41+
agreementSigned: O.none,
42+
isSuperUser,
43+
superUserSince: O.none,
44+
gravatarHash: 'hash' as unknown as GravatarHash,
45+
status: 'active',
46+
joined: new Date('2025-01-01T00:00:00.000Z'),
47+
trainedOn: [],
48+
trainerFor: [],
49+
ownerOf: [],
50+
},
51+
user: {
52+
memberNumber: 999,
53+
emailAddress: 'viewer@example.com' as EmailAddress,
54+
},
55+
isSelf: false,
56+
isSuperUser,
57+
trainingMatrix: [],
58+
});
59+
60+
const renderPage = (viewModel: ViewModel) => {
61+
const rendered = render(viewModel);
62+
const body = document.createElement('body');
63+
body.innerHTML = rendered;
64+
return body;
65+
};
66+
67+
describe('member render', () => {
68+
it('shows the full email list with primary and verification statuses', () => {
69+
const page = renderPage(buildViewModel(true));
70+
71+
expect(page.textContent).toContain('Email addresses');
72+
expect(page.textContent).toContain(primaryEmail);
73+
expect(page.textContent).toContain(verifiedSecondaryEmail);
74+
expect(page.textContent).toContain(unverifiedEmail);
75+
expect(page.textContent).toContain('Primary');
76+
expect(page.textContent).toContain('Verified');
77+
expect(page.textContent).toContain('Unverified');
78+
});
79+
80+
it('shows the add email action to super users', () => {
81+
const page = renderPage(buildViewModel(true));
82+
const addEmailLink = page.querySelector<HTMLAnchorElement>(
83+
'a[href="/members/add-email?member=123"]'
84+
);
85+
86+
expect(addEmailLink?.textContent).toContain('Add email');
87+
});
88+
89+
it('does not show the add email action to non-super users', () => {
90+
const page = renderPage(buildViewModel(false));
91+
const addEmailLink = page.querySelector<HTMLAnchorElement>(
92+
'a[href="/members/add-email?member=123"]'
93+
);
94+
95+
expect(addEmailLink).toBeNull();
96+
});
97+
});

0 commit comments

Comments
 (0)