@learncard/email-templates 1.0.6 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/previews/credential-updated-scenarios.d.ts +9 -0
- package/dist/previews/credential-updated-scenarios.d.ts.map +1 -0
- package/dist/previews/credential-updated-scenarios.js +7 -0
- package/dist/previews/credential-updated-scenarios.js.map +1 -0
- package/dist/previews/recovery-key-scenarios.d.ts.map +1 -1
- package/dist/previews/recovery-key-scenarios.js +1 -1
- package/dist/previews/recovery-key-scenarios.js.map +1 -1
- package/dist/render.d.ts +16 -0
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +16 -1
- package/dist/render.js.map +1 -1
- package/dist/templates/account-sign-in-changed.d.ts +11 -0
- package/dist/templates/account-sign-in-changed.d.ts.map +1 -0
- package/dist/templates/account-sign-in-changed.js +70 -0
- package/dist/templates/account-sign-in-changed.js.map +1 -0
- package/dist/templates/credential-updated.d.ts +33 -0
- package/dist/templates/credential-updated.d.ts.map +1 -0
- package/dist/templates/credential-updated.js +104 -0
- package/dist/templates/credential-updated.js.map +1 -0
- package/dist/templates/index.d.ts +7 -3
- package/dist/templates/index.d.ts.map +1 -1
- package/dist/templates/index.js +5 -3
- package/dist/templates/index.js.map +1 -1
- package/dist/templates/recovery-key.d.ts +1 -0
- package/dist/templates/recovery-key.d.ts.map +1 -1
- package/dist/templates/recovery-key.js +11 -3
- package/dist/templates/recovery-key.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/i18n.test.ts +9 -2
- package/src/__tests__/render.test.ts +151 -26
- package/src/index.ts +2 -0
- package/src/previews/credential-updated-scenarios.tsx +61 -0
- package/src/previews/recovery-key-scenarios.tsx +3 -0
- package/src/render.ts +39 -0
- package/src/templates/account-sign-in-changed.tsx +113 -0
- package/src/templates/credential-updated.tsx +309 -0
- package/src/templates/index.ts +17 -3
- package/src/templates/recovery-key.tsx +26 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"recovery-key.js","sourceRoot":"","sources":["../../src/templates/recovery-key.tsx"],"names":[],"mappings":";AAAA;;;;GAIG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,yBAAyB,CAAC;AAI/C,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,EAAE,MAAM,EAAE,MAAM,sBAAsB,CAAC;AAC9C,OAAO,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAEpD,OAAO,EAAE,oBAAoB,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"recovery-key.js","sourceRoot":"","sources":["../../src/templates/recovery-key.tsx"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@learncard/email-templates",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Git-managed, tenant-branded email and SMS templates for LearnCard services",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"@types/react": "^18.3.0",
|
|
27
27
|
"react-email": "^3.0.0",
|
|
28
28
|
"typescript": "^5.0.0",
|
|
29
|
-
"vitest": "4.1.
|
|
29
|
+
"vitest": "4.1.11"
|
|
30
30
|
},
|
|
31
31
|
"author": "Learning Economy Foundation (www.learningeconomy.io)",
|
|
32
32
|
"license": "MIT",
|
|
@@ -49,8 +49,14 @@ const FIXTURES: { [K in TemplateId]: TemplateDataMap[K] } = {
|
|
|
49
49
|
},
|
|
50
50
|
'account-approved': { user: { displayName: 'New User' } },
|
|
51
51
|
'account-approved-email': { user: { displayName: 'New User' } },
|
|
52
|
-
'recovery-key': {
|
|
53
|
-
|
|
52
|
+
'recovery-key': {
|
|
53
|
+
recoveryKey: 'mango-delta-fox-echo-bravo-seven-lima-niner',
|
|
54
|
+
confirmationCode: '123456',
|
|
55
|
+
},
|
|
56
|
+
'recovery-key-backup': {
|
|
57
|
+
recoveryKey: 'mango-delta-fox-echo-bravo-seven-lima-niner',
|
|
58
|
+
confirmationCode: '123456',
|
|
59
|
+
},
|
|
54
60
|
'endorsement-request': {
|
|
55
61
|
shareLink: 'https://learncard.app/share/xyz',
|
|
56
62
|
recipient: { name: 'Dr. Emily Chen' },
|
|
@@ -85,6 +91,7 @@ const FIXTURES: { [K in TemplateId]: TemplateDataMap[K] } = {
|
|
|
85
91
|
credential: { name: 'Perfect Attendance Award' },
|
|
86
92
|
recipient: { email: 'student@example.com' },
|
|
87
93
|
},
|
|
94
|
+
'account-sign-in-changed': {},
|
|
88
95
|
};
|
|
89
96
|
|
|
90
97
|
const ALL_TEMPLATE_IDS = Object.keys(FIXTURES) as TemplateId[];
|
|
@@ -83,10 +83,12 @@ const TEMPLATE_FIXTURES: { [K in TemplateId]: TemplateDataMap[K] } = {
|
|
|
83
83
|
|
|
84
84
|
'recovery-key': {
|
|
85
85
|
recoveryKey: 'ABCD-EFGH-1234-5678',
|
|
86
|
+
confirmationCode: '123456',
|
|
86
87
|
},
|
|
87
88
|
|
|
88
89
|
'recovery-key-backup': {
|
|
89
90
|
recoveryKey: 'WXYZ-9876-LMNO-4321',
|
|
91
|
+
confirmationCode: '654321',
|
|
90
92
|
},
|
|
91
93
|
|
|
92
94
|
'endorsement-request': {
|
|
@@ -130,6 +132,12 @@ const TEMPLATE_FIXTURES: { [K in TemplateId]: TemplateDataMap[K] } = {
|
|
|
130
132
|
credential: { name: 'Diploma' },
|
|
131
133
|
recipient: { email: 'student@example.com' },
|
|
132
134
|
},
|
|
135
|
+
|
|
136
|
+
'credential-updated': {
|
|
137
|
+
issuer: { name: 'Inbox Demo School' },
|
|
138
|
+
credential: { name: 'Introduction to Biology' },
|
|
139
|
+
},
|
|
140
|
+
'account-sign-in-changed': {},
|
|
133
141
|
};
|
|
134
142
|
|
|
135
143
|
const ALL_TEMPLATE_IDS = Object.keys(TEMPLATE_FIXTURES) as TemplateId[];
|
|
@@ -139,38 +147,40 @@ const ALL_TEMPLATE_IDS = Object.keys(TEMPLATE_FIXTURES) as TemplateId[];
|
|
|
139
147
|
// ---------------------------------------------------------------------------
|
|
140
148
|
|
|
141
149
|
describe('renderEmail — smoke tests', () => {
|
|
142
|
-
it.each(ALL_TEMPLATE_IDS)(
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
150
|
+
it.each(ALL_TEMPLATE_IDS)('"%s" renders with default branding', async templateId => {
|
|
151
|
+
const result = await renderEmail(
|
|
152
|
+
templateId,
|
|
153
|
+
DEFAULT_BRANDING,
|
|
154
|
+
TEMPLATE_FIXTURES[templateId]
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
expect(result.html).toBeTruthy();
|
|
158
|
+
expect(result.html).toContain('<!DOCTYPE');
|
|
159
|
+
expect(result.text).toBeTruthy();
|
|
160
|
+
expect(result.subject).toBeTruthy();
|
|
161
|
+
expect(result.subject.length).toBeGreaterThan(0);
|
|
162
|
+
});
|
|
154
163
|
|
|
155
|
-
it.each(ALL_TEMPLATE_IDS)(
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
164
|
+
it.each(ALL_TEMPLATE_IDS)('"%s" renders with custom (VetPass) branding', async templateId => {
|
|
165
|
+
const result = await renderEmail(
|
|
166
|
+
templateId,
|
|
167
|
+
VETPASS_BRANDING,
|
|
168
|
+
TEMPLATE_FIXTURES[templateId]
|
|
169
|
+
);
|
|
159
170
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
);
|
|
171
|
+
expect(result.html).toBeTruthy();
|
|
172
|
+
expect(result.text).toBeTruthy();
|
|
173
|
+
expect(result.subject).toBeTruthy();
|
|
174
|
+
});
|
|
165
175
|
|
|
166
176
|
it.each(ALL_TEMPLATE_IDS)(
|
|
167
177
|
'"%s" renders with empty branding (falls back to defaults)',
|
|
168
|
-
async
|
|
178
|
+
async templateId => {
|
|
169
179
|
const result = await renderEmail(templateId, {}, TEMPLATE_FIXTURES[templateId]);
|
|
170
180
|
|
|
171
181
|
expect(result.html).toBeTruthy();
|
|
172
182
|
expect(result.subject).toBeTruthy();
|
|
173
|
-
}
|
|
183
|
+
}
|
|
174
184
|
);
|
|
175
185
|
});
|
|
176
186
|
|
|
@@ -244,6 +254,7 @@ describe('renderEmail — content assertions', () => {
|
|
|
244
254
|
const { html } = await renderEmail('recovery-key', DEFAULT_BRANDING, data);
|
|
245
255
|
|
|
246
256
|
expect(html).toContain('ABCD-EFGH-1234-5678');
|
|
257
|
+
expect(html).toContain('123456');
|
|
247
258
|
});
|
|
248
259
|
|
|
249
260
|
it('endorsement-request includes the share link and message', async () => {
|
|
@@ -292,6 +303,89 @@ describe('renderEmail — content assertions', () => {
|
|
|
292
303
|
});
|
|
293
304
|
});
|
|
294
305
|
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// credential-updated — minimal-context update email (LC-2198)
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
describe('renderEmail — credential-updated', () => {
|
|
311
|
+
it('includes the issuer display name and credential title', async () => {
|
|
312
|
+
const data = TEMPLATE_FIXTURES['credential-updated'];
|
|
313
|
+
const { html } = await renderEmail('credential-updated', DEFAULT_BRANDING, data);
|
|
314
|
+
|
|
315
|
+
expect(html).toContain('Inbox Demo School');
|
|
316
|
+
expect(html).toContain('Introduction to Biology');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('links the CTA to the configured app notifications page, not a claim or token URL', async () => {
|
|
320
|
+
const { html } = await renderEmail('credential-updated', VETPASS_BRANDING, {
|
|
321
|
+
issuer: { name: 'Vet School' },
|
|
322
|
+
credential: { name: 'Veterinary Ethics' },
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
expect(html).toContain('https://vetpass.app/notifications');
|
|
326
|
+
expect(html).not.toContain('/claim');
|
|
327
|
+
expect(html).not.toContain('token=');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('falls back to generic copy when no credential title is available', async () => {
|
|
331
|
+
const { html, text } = await renderEmail('credential-updated', DEFAULT_BRANDING, {
|
|
332
|
+
issuer: { name: 'Inbox Demo School' },
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
expect(html).toContain('Inbox Demo School');
|
|
336
|
+
// Generic copy confirms an update without inventing a title.
|
|
337
|
+
expect(html.toLowerCase()).toContain('updated');
|
|
338
|
+
expect(text.toLowerCase()).toContain('updated');
|
|
339
|
+
expect(html).not.toContain('undefined');
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('never renders issuer-authored summary or credential body content', async () => {
|
|
343
|
+
// The data shape only carries issuer + title; assert nothing else leaks.
|
|
344
|
+
const { html } = await renderEmail('credential-updated', DEFAULT_BRANDING, {
|
|
345
|
+
issuer: { name: 'Inbox Demo School' },
|
|
346
|
+
credential: { name: 'Introduction to Biology' },
|
|
347
|
+
} as unknown as (typeof TEMPLATE_FIXTURES)['credential-updated']);
|
|
348
|
+
|
|
349
|
+
expect(html).not.toContain('updateSummary');
|
|
350
|
+
expect(html).not.toContain('credentialSubject');
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it.each([
|
|
354
|
+
{ locale: 'es', expected: 'actualiz' },
|
|
355
|
+
{ locale: 'fr', expected: 'mis' },
|
|
356
|
+
{ locale: 'ar', expected: 'تم' },
|
|
357
|
+
])('renders localized copy for $locale', async ({ locale, expected }) => {
|
|
358
|
+
const data = TEMPLATE_FIXTURES['credential-updated'];
|
|
359
|
+
const { html, subject } = await renderEmail(
|
|
360
|
+
'credential-updated',
|
|
361
|
+
DEFAULT_BRANDING,
|
|
362
|
+
data,
|
|
363
|
+
locale
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
expect(subject).toBeTruthy();
|
|
367
|
+
expect(html).toContain(expected);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('plain text output is readable and free of HTML tags', async () => {
|
|
371
|
+
const data = TEMPLATE_FIXTURES['credential-updated'];
|
|
372
|
+
const { text } = await renderEmail('credential-updated', DEFAULT_BRANDING, data);
|
|
373
|
+
|
|
374
|
+
expect(text).not.toMatch(/<[a-z][\s\S]*>/i);
|
|
375
|
+
expect(text).toContain('Inbox Demo School');
|
|
376
|
+
expect(text).toContain('Introduction to Biology');
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('flows tenant branding through to the rendered email', async () => {
|
|
380
|
+
const data = TEMPLATE_FIXTURES['credential-updated'];
|
|
381
|
+
const { html } = await renderEmail('credential-updated', VETPASS_BRANDING, data);
|
|
382
|
+
|
|
383
|
+
expect(html).toContain('VetPass');
|
|
384
|
+
expect(html).toContain('#1B5E20');
|
|
385
|
+
expect(html).toContain('support@vetpass.app');
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
295
389
|
// ---------------------------------------------------------------------------
|
|
296
390
|
// Branding tests — custom branding flows through to rendered output
|
|
297
391
|
// ---------------------------------------------------------------------------
|
|
@@ -366,10 +460,33 @@ describe('renderEmail — subjects', () => {
|
|
|
366
460
|
expect(subject).toBe('Verify Your Email');
|
|
367
461
|
});
|
|
368
462
|
|
|
463
|
+
it('credential-updated subject is localized and generic', async () => {
|
|
464
|
+
const [en, es] = await Promise.all([
|
|
465
|
+
renderEmail(
|
|
466
|
+
'credential-updated',
|
|
467
|
+
DEFAULT_BRANDING,
|
|
468
|
+
TEMPLATE_FIXTURES['credential-updated']
|
|
469
|
+
),
|
|
470
|
+
renderEmail(
|
|
471
|
+
'credential-updated',
|
|
472
|
+
DEFAULT_BRANDING,
|
|
473
|
+
TEMPLATE_FIXTURES['credential-updated'],
|
|
474
|
+
'es'
|
|
475
|
+
),
|
|
476
|
+
]);
|
|
477
|
+
|
|
478
|
+
expect(en.subject).toBe('Your credential was updated');
|
|
479
|
+
expect(es.subject).toBe('Tu credencial se actualizó');
|
|
480
|
+
});
|
|
481
|
+
|
|
369
482
|
it('Postmark alias pairs produce the same subject', async () => {
|
|
370
483
|
const [a, b] = await Promise.all([
|
|
371
484
|
renderEmail('inbox-claim', DEFAULT_BRANDING, TEMPLATE_FIXTURES['inbox-claim']),
|
|
372
|
-
renderEmail(
|
|
485
|
+
renderEmail(
|
|
486
|
+
'universal-inbox-claim',
|
|
487
|
+
DEFAULT_BRANDING,
|
|
488
|
+
TEMPLATE_FIXTURES['inbox-claim']
|
|
489
|
+
),
|
|
373
490
|
]);
|
|
374
491
|
|
|
375
492
|
expect(a.subject).toBe(b.subject);
|
|
@@ -382,13 +499,21 @@ describe('renderEmail — subjects', () => {
|
|
|
382
499
|
|
|
383
500
|
describe('renderEmail — plain text', () => {
|
|
384
501
|
it('plain text output does not contain HTML tags', async () => {
|
|
385
|
-
const { text } = await renderEmail(
|
|
502
|
+
const { text } = await renderEmail(
|
|
503
|
+
'inbox-claim',
|
|
504
|
+
DEFAULT_BRANDING,
|
|
505
|
+
TEMPLATE_FIXTURES['inbox-claim']
|
|
506
|
+
);
|
|
386
507
|
|
|
387
508
|
expect(text).not.toMatch(/<[a-z][\s\S]*>/i);
|
|
388
509
|
});
|
|
389
510
|
|
|
390
511
|
it('plain text includes key content', async () => {
|
|
391
|
-
const { text } = await renderEmail(
|
|
512
|
+
const { text } = await renderEmail(
|
|
513
|
+
'recovery-key',
|
|
514
|
+
DEFAULT_BRANDING,
|
|
515
|
+
TEMPLATE_FIXTURES['recovery-key']
|
|
516
|
+
);
|
|
392
517
|
|
|
393
518
|
expect(text).toContain('ABCD-EFGH-1234-5678');
|
|
394
519
|
});
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* credential-updated — minimal-context update notice scenarios.
|
|
3
|
+
*
|
|
4
|
+
* Shows the title/no-title fallback and locale variants so reviewers can verify
|
|
5
|
+
* the email never renders grades, subject details, or issuer summaries.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as React from 'react';
|
|
9
|
+
|
|
10
|
+
import { CredentialUpdated } from '../templates/credential-updated';
|
|
11
|
+
import { LEARNCARD_BRANDING, VETPASS_BRANDING, ScenarioDivider } from './_fixtures';
|
|
12
|
+
|
|
13
|
+
export default function CredentialUpdatedScenarios() {
|
|
14
|
+
return (
|
|
15
|
+
<>
|
|
16
|
+
<ScenarioDivider label="English — with credential title" />
|
|
17
|
+
<CredentialUpdated
|
|
18
|
+
branding={LEARNCARD_BRANDING}
|
|
19
|
+
issuer={{ name: 'Inbox Demo School' }}
|
|
20
|
+
credential={{ name: 'Introduction to Biology' }}
|
|
21
|
+
/>
|
|
22
|
+
|
|
23
|
+
<ScenarioDivider label="English — title unavailable (generic fallback)" />
|
|
24
|
+
<CredentialUpdated
|
|
25
|
+
branding={LEARNCARD_BRANDING}
|
|
26
|
+
issuer={{ name: 'Inbox Demo School' }}
|
|
27
|
+
/>
|
|
28
|
+
|
|
29
|
+
<ScenarioDivider label="Spanish" />
|
|
30
|
+
<CredentialUpdated
|
|
31
|
+
branding={LEARNCARD_BRANDING}
|
|
32
|
+
issuer={{ name: 'Escuela Demo' }}
|
|
33
|
+
credential={{ name: 'Introducción a la Biología' }}
|
|
34
|
+
locale="es"
|
|
35
|
+
/>
|
|
36
|
+
|
|
37
|
+
<ScenarioDivider label="French" />
|
|
38
|
+
<CredentialUpdated
|
|
39
|
+
branding={LEARNCARD_BRANDING}
|
|
40
|
+
issuer={{ name: 'École Démo' }}
|
|
41
|
+
credential={{ name: 'Introduction à la biologie' }}
|
|
42
|
+
locale="fr"
|
|
43
|
+
/>
|
|
44
|
+
|
|
45
|
+
<ScenarioDivider label="Arabic (RTL)" />
|
|
46
|
+
<CredentialUpdated
|
|
47
|
+
branding={LEARNCARD_BRANDING}
|
|
48
|
+
issuer={{ name: 'مدرسة تجريبية' }}
|
|
49
|
+
credential={{ name: 'مقدمة في علم الأحياء' }}
|
|
50
|
+
locale="ar"
|
|
51
|
+
/>
|
|
52
|
+
|
|
53
|
+
<ScenarioDivider label="VetPass branding" />
|
|
54
|
+
<CredentialUpdated
|
|
55
|
+
branding={VETPASS_BRANDING}
|
|
56
|
+
issuer={{ name: 'VetPass Academy' }}
|
|
57
|
+
credential={{ name: 'Veterinary Ethics' }}
|
|
58
|
+
/>
|
|
59
|
+
</>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -14,18 +14,21 @@ export default function RecoveryKeyScenarios() {
|
|
|
14
14
|
<RecoveryKey
|
|
15
15
|
branding={LEARNCARD_BRANDING}
|
|
16
16
|
recoveryKey="mango-delta-fox-echo-bravo-seven-lima-niner"
|
|
17
|
+
confirmationCode="123456"
|
|
17
18
|
/>
|
|
18
19
|
|
|
19
20
|
<ScenarioDivider label="Long recovery key (wrapping test)" />
|
|
20
21
|
<RecoveryKey
|
|
21
22
|
branding={LEARNCARD_BRANDING}
|
|
22
23
|
recoveryKey="alpha-bravo-charlie-delta-echo-foxtrot-golf-hotel-india-juliet-kilo-lima"
|
|
24
|
+
confirmationCode="654321"
|
|
23
25
|
/>
|
|
24
26
|
|
|
25
27
|
<ScenarioDivider label="VetPass tenant branding" />
|
|
26
28
|
<RecoveryKey
|
|
27
29
|
branding={VETPASS_BRANDING}
|
|
28
30
|
recoveryKey="sierra-tango-uniform-victor-whiskey-xray"
|
|
31
|
+
confirmationCode="987654"
|
|
29
32
|
/>
|
|
30
33
|
</>
|
|
31
34
|
);
|
package/src/render.ts
CHANGED
|
@@ -39,6 +39,10 @@ import {
|
|
|
39
39
|
getGuardianRejectedCredentialSubject,
|
|
40
40
|
EmailVerification,
|
|
41
41
|
getEmailVerificationSubject,
|
|
42
|
+
CredentialUpdated,
|
|
43
|
+
getCredentialUpdatedSubject,
|
|
44
|
+
AccountSignInChanged,
|
|
45
|
+
getAccountSignInChangedSubject,
|
|
42
46
|
} from './templates';
|
|
43
47
|
|
|
44
48
|
import type {
|
|
@@ -53,6 +57,8 @@ import type {
|
|
|
53
57
|
GuardianEmailOtpProps,
|
|
54
58
|
GuardianRejectedCredentialProps,
|
|
55
59
|
EmailVerificationProps,
|
|
60
|
+
CredentialUpdatedProps,
|
|
61
|
+
AccountSignInChangedProps,
|
|
56
62
|
} from './templates';
|
|
57
63
|
|
|
58
64
|
// ---------------------------------------------------------------------------
|
|
@@ -146,6 +152,11 @@ export interface TemplateDataMap {
|
|
|
146
152
|
|
|
147
153
|
/** Sent to student: guardian rejected credential */
|
|
148
154
|
'guardian-rejected-credential': GuardianRejectedCredentialData;
|
|
155
|
+
|
|
156
|
+
/** brain-service: managed credential refresh update notice (LC-2198) */
|
|
157
|
+
'credential-updated': CredentialUpdatedData;
|
|
158
|
+
/** lca-api: lost-login identity rebind security notification */
|
|
159
|
+
'account-sign-in-changed': AccountSignInChangedData;
|
|
149
160
|
}
|
|
150
161
|
|
|
151
162
|
export type TemplateId = keyof TemplateDataMap;
|
|
@@ -184,6 +195,7 @@ export interface AccountApprovedData {
|
|
|
184
195
|
|
|
185
196
|
export interface RecoveryKeyData {
|
|
186
197
|
recoveryKey: string;
|
|
198
|
+
confirmationCode: string;
|
|
187
199
|
}
|
|
188
200
|
|
|
189
201
|
export interface EndorsementRequestData {
|
|
@@ -223,6 +235,14 @@ export interface GuardianRejectedCredentialData {
|
|
|
223
235
|
recipient?: { email?: string };
|
|
224
236
|
}
|
|
225
237
|
|
|
238
|
+
export interface CredentialUpdatedData {
|
|
239
|
+
issuer?: { name?: string; logoUrl?: string };
|
|
240
|
+
/** Bounded credential display title. Omitted when unavailable. */
|
|
241
|
+
credential?: { name?: string };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export type AccountSignInChangedData = Record<string, never>;
|
|
245
|
+
|
|
226
246
|
// ---------------------------------------------------------------------------
|
|
227
247
|
// renderEmail()
|
|
228
248
|
// ---------------------------------------------------------------------------
|
|
@@ -420,6 +440,25 @@ function buildElement(
|
|
|
420
440
|
};
|
|
421
441
|
}
|
|
422
442
|
|
|
443
|
+
case 'credential-updated': {
|
|
444
|
+
const d = data as CredentialUpdatedData;
|
|
445
|
+
const props: CredentialUpdatedProps = { branding, ...d, locale };
|
|
446
|
+
|
|
447
|
+
return {
|
|
448
|
+
element: React.createElement(CredentialUpdated, props),
|
|
449
|
+
subject: getCredentialUpdatedSubject(branding, locale),
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
case 'account-sign-in-changed': {
|
|
454
|
+
const props: AccountSignInChangedProps = { branding, locale };
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
element: React.createElement(AccountSignInChanged, props),
|
|
458
|
+
subject: getAccountSignInChangedSubject(branding, locale),
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
423
462
|
default: {
|
|
424
463
|
const _exhaustive: never = templateId;
|
|
425
464
|
throw new Error(`Unknown template: ${_exhaustive}`);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Text } from '@react-email/components';
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
|
|
4
|
+
import type { TenantBranding } from '../branding';
|
|
5
|
+
import { DEFAULT_BRANDING } from '../branding';
|
|
6
|
+
import { Layout } from '../components/Layout';
|
|
7
|
+
import type { NotificationLocale } from '../i18n';
|
|
8
|
+
import { interpolate, resolveCatalogLocale, SHARED } from '../i18n';
|
|
9
|
+
|
|
10
|
+
export interface AccountSignInChangedProps {
|
|
11
|
+
branding: TenantBranding;
|
|
12
|
+
locale?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const STRINGS: Record<
|
|
16
|
+
NotificationLocale,
|
|
17
|
+
{
|
|
18
|
+
preview: string;
|
|
19
|
+
heading: string;
|
|
20
|
+
body: string;
|
|
21
|
+
warning: string;
|
|
22
|
+
subject: string;
|
|
23
|
+
sincerely: string;
|
|
24
|
+
}
|
|
25
|
+
> = {
|
|
26
|
+
en: {
|
|
27
|
+
preview: 'Your account sign-in was changed',
|
|
28
|
+
heading: 'Your Sign-In Was Changed',
|
|
29
|
+
body: 'Your {brandName} account was restored and connected to a new sign-in.',
|
|
30
|
+
warning: 'If this was not you, contact support immediately.',
|
|
31
|
+
subject: 'Your account sign-in was changed',
|
|
32
|
+
sincerely: 'Sincerely,',
|
|
33
|
+
},
|
|
34
|
+
es: {
|
|
35
|
+
preview: 'Se cambió el inicio de sesión de tu cuenta',
|
|
36
|
+
heading: 'Se cambió tu inicio de sesión',
|
|
37
|
+
body: 'Tu cuenta de {brandName} se restauró y se conectó a un nuevo inicio de sesión.',
|
|
38
|
+
warning: 'Si no fuiste tú, comunícate con soporte de inmediato.',
|
|
39
|
+
subject: 'Se cambió el inicio de sesión de tu cuenta',
|
|
40
|
+
sincerely: 'Atentamente,',
|
|
41
|
+
},
|
|
42
|
+
fr: {
|
|
43
|
+
preview: 'La connexion à votre compte a été modifiée',
|
|
44
|
+
heading: 'Votre connexion a été modifiée',
|
|
45
|
+
body: 'Votre compte {brandName} a été restauré et associé à une nouvelle connexion.',
|
|
46
|
+
warning:
|
|
47
|
+
'Si vous n’êtes pas à l’origine de cette action, contactez immédiatement l’assistance.',
|
|
48
|
+
subject: 'La connexion à votre compte a été modifiée',
|
|
49
|
+
sincerely: 'Cordialement,',
|
|
50
|
+
},
|
|
51
|
+
ar: {
|
|
52
|
+
preview: 'تم تغيير تسجيل الدخول إلى حسابك',
|
|
53
|
+
heading: 'تم تغيير تسجيل الدخول',
|
|
54
|
+
body: 'تمت استعادة حسابك في {brandName} وربطه بتسجيل دخول جديد.',
|
|
55
|
+
warning: 'إذا لم تكن أنت من أجرى هذا التغيير، فتواصل مع الدعم فورًا.',
|
|
56
|
+
subject: 'تم تغيير تسجيل الدخول إلى حسابك',
|
|
57
|
+
sincerely: 'مع التحيات،',
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const AccountSignInChanged: React.FC<AccountSignInChangedProps> = ({ branding, locale }) => {
|
|
62
|
+
const strings = STRINGS[resolveCatalogLocale(locale)];
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<Layout branding={branding} locale={locale} preview={strings.preview}>
|
|
66
|
+
<Text style={heading}>{strings.heading}</Text>
|
|
67
|
+
<Text style={paragraph}>
|
|
68
|
+
{interpolate(strings.body, { brandName: branding.brandName })}
|
|
69
|
+
</Text>
|
|
70
|
+
<Text style={warning}>{strings.warning}</Text>
|
|
71
|
+
<Text style={signOff}>
|
|
72
|
+
{strings.sincerely}
|
|
73
|
+
<br />
|
|
74
|
+
{interpolate(SHARED[resolveCatalogLocale(locale)].teamSignature, {
|
|
75
|
+
brandName: branding.brandName,
|
|
76
|
+
})}
|
|
77
|
+
</Text>
|
|
78
|
+
</Layout>
|
|
79
|
+
);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Return the localized subject for an account sign-in change notification. */
|
|
83
|
+
export const getAccountSignInChangedSubject = (
|
|
84
|
+
_branding: TenantBranding,
|
|
85
|
+
locale?: string
|
|
86
|
+
): string => STRINGS[resolveCatalogLocale(locale)].subject;
|
|
87
|
+
|
|
88
|
+
const heading: React.CSSProperties = {
|
|
89
|
+
color: '#111827',
|
|
90
|
+
fontSize: '24px',
|
|
91
|
+
fontWeight: 600,
|
|
92
|
+
lineHeight: '32px',
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const paragraph: React.CSSProperties = {
|
|
96
|
+
color: '#374151',
|
|
97
|
+
fontSize: '16px',
|
|
98
|
+
lineHeight: '24px',
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const warning: React.CSSProperties = {
|
|
102
|
+
...paragraph,
|
|
103
|
+
fontWeight: 600,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const signOff: React.CSSProperties = {
|
|
107
|
+
...paragraph,
|
|
108
|
+
marginTop: '24px',
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export default function Preview(): React.ReactElement {
|
|
112
|
+
return <AccountSignInChanged branding={DEFAULT_BRANDING} />;
|
|
113
|
+
}
|