@reuters-graphics/gfx-better-auth 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +81 -0
  2. package/dist/client/index.d.ts +142 -0
  3. package/dist/client/index.js +32 -0
  4. package/dist/domains.d.ts +24 -0
  5. package/dist/domains.js +30 -0
  6. package/dist/emails/Template.d.ts +36 -0
  7. package/dist/emails/Template.js +82 -0
  8. package/dist/emails/_components/Brand.d.ts +13 -0
  9. package/dist/emails/_components/Brand.js +28 -0
  10. package/dist/emails/_components/CentreCard.d.ts +10 -0
  11. package/dist/emails/_components/CentreCard.js +22 -0
  12. package/dist/emails/_components/CentreWell.d.ts +7 -0
  13. package/dist/emails/_components/CentreWell.js +9 -0
  14. package/dist/emails/_components/Footer.d.ts +7 -0
  15. package/dist/emails/_components/Footer.js +26 -0
  16. package/dist/emails/_components/Head.d.ts +10 -0
  17. package/dist/emails/_components/Head.js +19 -0
  18. package/dist/emails/_components/OpenButton.d.ts +8 -0
  19. package/dist/emails/_components/OpenButton.js +21 -0
  20. package/dist/emails/_components/index.d.ts +14 -0
  21. package/dist/emails/_components/index.js +14 -0
  22. package/dist/emails/_components/tokens.d.ts +28 -0
  23. package/dist/emails/_components/tokens.js +28 -0
  24. package/dist/emails/index.d.ts +96 -0
  25. package/dist/emails/index.js +160 -0
  26. package/dist/index.d.ts +9 -0
  27. package/dist/index.js +9 -0
  28. package/dist/schema/index.d.ts +718 -0
  29. package/dist/schema/index.js +108 -0
  30. package/dist/server/auth.d.ts +67 -0
  31. package/dist/server/auth.js +152 -0
  32. package/dist/server/config.d.ts +81 -0
  33. package/dist/server/config.js +105 -0
  34. package/dist/server/dev.d.ts +15 -0
  35. package/dist/server/dev.js +48 -0
  36. package/dist/server/escape.d.ts +9 -0
  37. package/dist/server/escape.js +28 -0
  38. package/dist/server/guard.d.ts +13 -0
  39. package/dist/server/guard.js +17 -0
  40. package/dist/server/handle.d.ts +62 -0
  41. package/dist/server/handle.js +252 -0
  42. package/dist/server/index.d.ts +12 -0
  43. package/dist/server/index.js +11 -0
  44. package/dist/server/pages.d.ts +53 -0
  45. package/dist/server/pages.js +133 -0
  46. package/dist/server/testing.d.ts +31 -0
  47. package/dist/server/testing.js +53 -0
  48. package/dist/types.d.ts +30 -0
  49. package/dist/types.js +1 -0
  50. package/package.json +101 -0
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # @reuters-graphics/gfx-better-auth
2
+
3
+ Magic-link sign-in for Reuters Graphics SvelteKit apps, gated to Thomson Reuters staff. An
4
+ opinionated [better-auth](https://better-auth.com) preset with the schema, routes, emails and dev
5
+ flow already wired.
6
+
7
+ > ### This package is for Reuters Graphics apps only
8
+ >
9
+ > The sign-in domain is **hardcoded to `@thomsonreuters.com`** and is deliberately not
10
+ > configurable, so the package cannot do anything useful outside Thomson Reuters. It is published
11
+ > publicly because that is how our own apps install it, not because it is a general-purpose
12
+ > library.
13
+ >
14
+ > **Full documentation lives in the repository**, which is private. If you work here, start with
15
+ > `USAGE.md`. If you don't, this is unlikely to be what you want —
16
+ > [better-auth](https://better-auth.com) itself almost certainly is.
17
+
18
+ ## Install
19
+
20
+ ```sh
21
+ pnpm add @reuters-graphics/gfx-better-auth
22
+ ```
23
+
24
+ Peers: `better-auth` (`>=1.7.5 <1.8.0`), `drizzle-orm`, `@sveltejs/kit`, `svelte`. Node ≥ 22, plus
25
+ a Postgres and a Drizzle instance.
26
+
27
+ ## Four edits
28
+
29
+ ```ts
30
+ // 1. src/lib/server/db/schema/index.ts
31
+ export * from '@reuters-graphics/gfx-better-auth/schema';
32
+
33
+ // 2. src/lib/server/auth.ts
34
+ import { createGfxAuth } from '@reuters-graphics/gfx-better-auth/server';
35
+ export const auth = createGfxAuth({
36
+ db,
37
+ appName,
38
+ baseURL,
39
+ secret,
40
+ postmark,
41
+ });
42
+
43
+ // 3. src/hooks.server.ts
44
+ import { building } from '$app/environment';
45
+ export const handle = sequence(gfxAuth({ auth, building }), myHandle);
46
+
47
+ // 4. src/app.d.ts
48
+ interface Locals extends GfxAuthLocals {}
49
+ ```
50
+
51
+ Then `pnpm db:generate && pnpm db:migrate`.
52
+
53
+ ⚠️ `gfxAuth` must come **before** anything reading `event.locals.user`. Pass `building` if your app
54
+ prerenders anything.
55
+
56
+ ## What you get
57
+
58
+ Magic-link sign-in gated at the endpoint; sign-in, sent, confirm and sign-out pages served from the
59
+ hook with no route files and no JavaScript; `event.locals.user` and `.session`; a route guard; and
60
+ a development flow that needs no credentials.
61
+
62
+ You do **not** get roles or permissions — this package proves who someone is, not what they may do.
63
+
64
+ ## Entry points
65
+
66
+ | | |
67
+ | ---------- | --------------------------------------------------------- |
68
+ | `.` | Types and the domain helpers. Safe anywhere |
69
+ | `./schema` | The four Drizzle tables, and columns to spread |
70
+ | `./server` | 🚨 `createGfxAuth`, the handle, `requireUser`, `signInAs` |
71
+ | `./client` | The browser client. Optional |
72
+ | `./emails` | The sign-in email, and `InactiveRecipientError` |
73
+
74
+ ## Everything else
75
+
76
+ Configuration, session semantics, extending the user model, testing, and the reasoning behind any
77
+ of it: **see `USAGE.md` in the repository.**
78
+
79
+ ---
80
+
81
+ MIT · [Reuters Graphics](https://github.com/reuters-graphics)
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Browser entry. Safe to import from components — nothing here touches the
3
+ * database, Postmark or a secret.
4
+ *
5
+ * ```ts
6
+ * // src/lib/auth-client.ts
7
+ * import { createGfxAuthClient } from '@reuters-graphics/gfx-better-auth/client';
8
+ * export const authClient = createGfxAuthClient();
9
+ *
10
+ * // in a component
11
+ * await authClient.signIn.magicLink({ email, callbackURL: '/' });
12
+ * await authClient.signOut();
13
+ * const session = authClient.useSession(); // a Svelte store
14
+ * ```
15
+ *
16
+ * ⚠️ **You usually do not need this.** The built-in pages at `/magic-link` are
17
+ * plain HTML forms served from the handle and require no client-side code at
18
+ * all (ADR-0008). Reach for this only when building your own sign-in UI.
19
+ *
20
+ * 🚨 **The domain gate is not here, and cannot be.** It runs in
21
+ * `hooks.before` on the server, because anything in the browser is advice
22
+ * rather than enforcement (ADR-0004). A non-Thomson-Reuters address fails with
23
+ * a 403 from the endpoint; surface that, do not pre-empt it.
24
+ */
25
+ export declare const createGfxAuthClient: (options?: {
26
+ baseURL?: string;
27
+ }) => import("better-auth/svelte").SvelteAuthClient<{
28
+ plugins: {
29
+ id: "magic-link";
30
+ version: string;
31
+ $InferServerPlugin: ReturnType<(options: import("better-auth/plugins").MagicLinkOptions) => {
32
+ id: "magic-link";
33
+ version: string;
34
+ endpoints: {
35
+ signInMagicLink: import("better-auth").StrictEndpoint<"/sign-in/magic-link", {
36
+ method: "POST";
37
+ requireHeaders: true;
38
+ use: import("better-auth").Middleware<import("better-auth").MiddlewareOptions, (inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<void>>[];
39
+ body: import("zod").ZodObject<{
40
+ email: import("zod").ZodEmail;
41
+ name: import("zod").ZodOptional<import("zod").ZodString>;
42
+ callbackURL: import("zod").ZodOptional<import("zod").ZodString>;
43
+ newUserCallbackURL: import("zod").ZodOptional<import("zod").ZodString>;
44
+ errorCallbackURL: import("zod").ZodOptional<import("zod").ZodString>;
45
+ metadata: import("zod").ZodOptional<import("zod").ZodRecord<import("zod").ZodString, import("zod").ZodAny>>;
46
+ }, import("zod/v4/core").$strip>;
47
+ metadata: {
48
+ openapi: {
49
+ operationId: string;
50
+ description: string;
51
+ responses: {
52
+ 200: {
53
+ description: string;
54
+ content: {
55
+ "application/json": {
56
+ schema: {
57
+ type: "object";
58
+ properties: {
59
+ status: {
60
+ type: string;
61
+ };
62
+ };
63
+ };
64
+ };
65
+ };
66
+ };
67
+ };
68
+ };
69
+ };
70
+ }, {
71
+ status: boolean;
72
+ }>;
73
+ magicLinkVerify: import("better-auth").StrictEndpoint<"/magic-link/verify", {
74
+ method: "GET";
75
+ query: import("zod").ZodObject<{
76
+ token: import("zod").ZodString;
77
+ callbackURL: import("zod").ZodOptional<import("zod").ZodString>;
78
+ errorCallbackURL: import("zod").ZodOptional<import("zod").ZodString>;
79
+ newUserCallbackURL: import("zod").ZodOptional<import("zod").ZodString>;
80
+ }, import("zod/v4/core").$strip>;
81
+ use: import("better-auth").Middleware<import("better-auth").MiddlewareOptions, (inputContext: import("better-auth").MiddlewareInputContext<import("better-auth").MiddlewareOptions>) => Promise<void>>[];
82
+ requireHeaders: true;
83
+ metadata: {
84
+ openapi: {
85
+ operationId: string;
86
+ description: string;
87
+ responses: {
88
+ 200: {
89
+ description: string;
90
+ content: {
91
+ "application/json": {
92
+ schema: {
93
+ type: "object";
94
+ properties: {
95
+ session: {
96
+ $ref: string;
97
+ };
98
+ user: {
99
+ $ref: string;
100
+ };
101
+ };
102
+ };
103
+ };
104
+ };
105
+ };
106
+ };
107
+ };
108
+ };
109
+ }, {
110
+ token: string;
111
+ user: {
112
+ id: string;
113
+ createdAt: Date;
114
+ updatedAt: Date;
115
+ email: string;
116
+ emailVerified: boolean;
117
+ name: string;
118
+ image?: string | null | undefined;
119
+ };
120
+ session: {
121
+ id: string;
122
+ createdAt: Date;
123
+ updatedAt: Date;
124
+ userId: string;
125
+ expiresAt: Date;
126
+ token: string;
127
+ ipAddress?: string | null | undefined;
128
+ userAgent?: string | null | undefined;
129
+ };
130
+ }>;
131
+ };
132
+ rateLimit: {
133
+ pathMatcher(path: string): boolean;
134
+ window: number;
135
+ max: number;
136
+ }[];
137
+ options: import("better-auth/plugins").MagicLinkOptions;
138
+ }>;
139
+ }[];
140
+ baseURL?: string | undefined;
141
+ }>;
142
+ export type GfxAuthClient = ReturnType<typeof createGfxAuthClient>;
@@ -0,0 +1,32 @@
1
+ import { createAuthClient } from 'better-auth/svelte';
2
+ import { magicLinkClient } from 'better-auth/client/plugins';
3
+ /**
4
+ * Browser entry. Safe to import from components — nothing here touches the
5
+ * database, Postmark or a secret.
6
+ *
7
+ * ```ts
8
+ * // src/lib/auth-client.ts
9
+ * import { createGfxAuthClient } from '@reuters-graphics/gfx-better-auth/client';
10
+ * export const authClient = createGfxAuthClient();
11
+ *
12
+ * // in a component
13
+ * await authClient.signIn.magicLink({ email, callbackURL: '/' });
14
+ * await authClient.signOut();
15
+ * const session = authClient.useSession(); // a Svelte store
16
+ * ```
17
+ *
18
+ * ⚠️ **You usually do not need this.** The built-in pages at `/magic-link` are
19
+ * plain HTML forms served from the handle and require no client-side code at
20
+ * all (ADR-0008). Reach for this only when building your own sign-in UI.
21
+ *
22
+ * 🚨 **The domain gate is not here, and cannot be.** It runs in
23
+ * `hooks.before` on the server, because anything in the browser is advice
24
+ * rather than enforcement (ADR-0004). A non-Thomson-Reuters address fails with
25
+ * a 403 from the endpoint; surface that, do not pre-empt it.
26
+ */
27
+ export const createGfxAuthClient = (options = {}) => createAuthClient({
28
+ // Defaults to the page's own origin, which is right for a SvelteKit app
29
+ // served from the same host as its API.
30
+ ...(options.baseURL ? { baseURL: options.baseURL } : {}),
31
+ plugins: [magicLinkClient()],
32
+ });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Who counts as staff.
3
+ *
4
+ * ⚠️ **`@tr.com` is deliberately not here, and it will look like a bug.**
5
+ * Two Thomson Reuters domains are in circulation — the same person appears in
6
+ * neighbouring repos as both `…@thomsonreuters.com` and `…@tr.com` — so
7
+ * someone will eventually "fix" this by adding the second one.
8
+ *
9
+ * Don't. **`@tr.com` does not receive mail** (api-questions § 1, answered
10
+ * 2026-09-23). Accepting it would mail a magic link into a void while the
11
+ * person sat on "check your inbox" forever — a worse failure than the refusal
12
+ * they get now, because it is silent and looks like our bug rather than a
13
+ * wrong address.
14
+ */
15
+ export declare const ALLOWED_EMAIL_DOMAINS: readonly ["thomsonreuters.com"];
16
+ /**
17
+ * Canonical form of an address, so one person is one identity.
18
+ *
19
+ * Lowercases, trims, and strips plus-addressing — otherwise
20
+ * `Jane.Doe+test@thomsonreuters.com` and `jane.doe@thomsonreuters.com` become
21
+ * two accounts with two sets of saved work.
22
+ */
23
+ export declare const normaliseEmail: (email: string) => string;
24
+ export declare const isAllowedEmail: (email: string) => boolean;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Who counts as staff.
3
+ *
4
+ * ⚠️ **`@tr.com` is deliberately not here, and it will look like a bug.**
5
+ * Two Thomson Reuters domains are in circulation — the same person appears in
6
+ * neighbouring repos as both `…@thomsonreuters.com` and `…@tr.com` — so
7
+ * someone will eventually "fix" this by adding the second one.
8
+ *
9
+ * Don't. **`@tr.com` does not receive mail** (api-questions § 1, answered
10
+ * 2026-09-23). Accepting it would mail a magic link into a void while the
11
+ * person sat on "check your inbox" forever — a worse failure than the refusal
12
+ * they get now, because it is silent and looks like our bug rather than a
13
+ * wrong address.
14
+ */
15
+ export const ALLOWED_EMAIL_DOMAINS = ['thomsonreuters.com'];
16
+ /**
17
+ * Canonical form of an address, so one person is one identity.
18
+ *
19
+ * Lowercases, trims, and strips plus-addressing — otherwise
20
+ * `Jane.Doe+test@thomsonreuters.com` and `jane.doe@thomsonreuters.com` become
21
+ * two accounts with two sets of saved work.
22
+ */
23
+ export const normaliseEmail = (email) => {
24
+ const [local = '', domain = ''] = email.trim().toLowerCase().split('@');
25
+ return `${local.split('+')[0]}@${domain}`;
26
+ };
27
+ export const isAllowedEmail = (email) => {
28
+ const domain = normaliseEmail(email).split('@')[1];
29
+ return (!!domain && ALLOWED_EMAIL_DOMAINS.includes(domain));
30
+ };
@@ -0,0 +1,36 @@
1
+ export interface SignInEmailContext {
2
+ /**
3
+ * Where the button points. 🚨 In this package that is
4
+ * `/magic-link/confirm?token=…`, **not** the verify endpoint — Outlook
5
+ * SafeLinks pre-fetches URLs in email and consumes single-use tokens, so a
6
+ * scanner must only ever reach a page (ADR-0002). This template does not
7
+ * enforce that; it is just the reason the URL it is handed looks the way it
8
+ * does.
9
+ */
10
+ url: string;
11
+ /** e.g. `"lightbox"`. Used in the subject, the heading and the button. */
12
+ appName: string;
13
+ /**
14
+ * How long the link is good for, **in seconds**. Optional, and omitted from
15
+ * the copy entirely when absent.
16
+ *
17
+ * ⚠️ `rngs.io` and decision-desk both hardcode "5 minutes" in the template.
18
+ * Ours is configurable, so a hardcoded number is a lie waiting to happen.
19
+ */
20
+ expiresIn?: number;
21
+ }
22
+ /**
23
+ * Seconds to something a person reads. Rounds to the nearest whole unit, and
24
+ * refuses to invent precision — 90 minutes reads as "2 hours", which is the
25
+ * right kind of wrong in a sentence that only sets expectations.
26
+ */
27
+ export declare const formatDuration: (seconds: number) => string;
28
+ /** The subject line, kept next to the template that has to match it. */
29
+ export declare const subject: (appName: string) => string;
30
+ /**
31
+ * The plain-text alternative. Hand-written rather than derived from the HTML:
32
+ * the one thing it must do is show the URL unmangled, and a converter is a
33
+ * strange thing to trust with that.
34
+ */
35
+ export declare const text: ({ url, appName, expiresIn }: SignInEmailContext) => string;
36
+ export declare const Template: ({ url, appName, expiresIn }: SignInEmailContext) => import("react").FunctionComponentElement<Readonly<Omit<import("react").DetailedHTMLProps<import("react").HtmlHTMLAttributes<HTMLHtmlElement>, HTMLHtmlElement>, "ref">> & import("react").RefAttributes<HTMLHtmlElement>>;
@@ -0,0 +1,82 @@
1
+ import { createElement as h } from 'react';
2
+ import { Body, Html, Link, Preview, Text } from 'react-email';
3
+ import { Brand, CentreCard, CentreWell, colors, fontFamily, Footer, Head, OpenButton, } from './_components/index.js';
4
+ const introStyle = {
5
+ fontSize: '16px',
6
+ lineHeight: '22px',
7
+ margin: '18px 0 0',
8
+ };
9
+ const fineStyle = {
10
+ color: colors.muted,
11
+ fontSize: '13px',
12
+ lineHeight: '19px',
13
+ margin: '0 0 12px',
14
+ };
15
+ const fallbackLinkStyle = {
16
+ color: colors.text,
17
+ fontSize: '13px',
18
+ lineHeight: '19px',
19
+ wordBreak: 'break-all',
20
+ };
21
+ const bodyStyle = {
22
+ backgroundColor: colors.page,
23
+ fontFamily,
24
+ margin: 0,
25
+ padding: 0,
26
+ };
27
+ /**
28
+ * Seconds to something a person reads. Rounds to the nearest whole unit, and
29
+ * refuses to invent precision — 90 minutes reads as "2 hours", which is the
30
+ * right kind of wrong in a sentence that only sets expectations.
31
+ */
32
+ export const formatDuration = (seconds) => {
33
+ if (!Number.isFinite(seconds) || seconds <= 0)
34
+ return 'a moment';
35
+ // Round within a unit *before* deciding whether to promote to the next one,
36
+ // so 3600s is "1 hour" rather than "60 minutes".
37
+ const s = Math.max(1, Math.round(seconds));
38
+ if (s < 60)
39
+ return `${s} ${s === 1 ? 'second' : 'seconds'}`;
40
+ const minutes = Math.round(s / 60);
41
+ if (minutes < 60)
42
+ return `${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`;
43
+ const hours = Math.round(s / 3600);
44
+ if (hours < 24)
45
+ return `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
46
+ const days = Math.round(s / 86400);
47
+ return `${days} ${days === 1 ? 'day' : 'days'}`;
48
+ };
49
+ /** The subject line, kept next to the template that has to match it. */
50
+ export const subject = (appName) => `Sign in to ${appName}`;
51
+ /**
52
+ * The plain-text alternative. Hand-written rather than derived from the HTML:
53
+ * the one thing it must do is show the URL unmangled, and a converter is a
54
+ * strange thing to trust with that.
55
+ */
56
+ export const text = ({ url, appName, expiresIn }) => [
57
+ `Sign in to ${appName}`,
58
+ '',
59
+ 'Open this link to sign in:',
60
+ url,
61
+ '',
62
+ expiresIn === undefined ?
63
+ 'The link can only be used once.'
64
+ : `The link expires in ${formatDuration(expiresIn)} and can only be used once.`,
65
+ '',
66
+ "If you didn't ask to sign in, you can ignore this email.",
67
+ ].join('\n');
68
+ export const Template = ({ url, appName, expiresIn }) => h(Html, { lang: 'en' }, h(Head, {
69
+ title: subject(appName),
70
+ description: `Sign in to ${appName}, from Reuters Graphics.`,
71
+ }),
72
+ // ⚠️ `useTitleTag: false`. react-email 6's `Preview` renders its own
73
+ // `<title>` by default, which React 19 hoists into `<head>` — so the
74
+ // obvious version of this template ships two `<title>` elements. The
75
+ // authoritative one is {@link Head}'s.
76
+ h(Preview, { useTitleTag: false, children: `Sign in to ${appName}` }), h(Body, { style: bodyStyle }, h(CentreCard, null, h(CentreWell, null, h(Brand, { appName }), h(Text, { style: introStyle }, 'Sign in below for newsroom tools from the global graphics team at Thomson Reuters:'),
77
+ // Children go in the props object, not the rest argument:
78
+ // `createElement`'s overloads do not satisfy a required `children`
79
+ // prop from varargs, so the rest form fails to typecheck.
80
+ h(OpenButton, { href: url, children: `Sign in to ${appName}` }), h(Text, { style: fineStyle }, 'Or paste this link into your browser:'), h(Text, { style: fineStyle }, h(Link, { href: url, style: fallbackLinkStyle }, url)), h(Text, { style: fineStyle }, expiresIn === undefined ?
81
+ 'The link can only be used once.'
82
+ : `The link expires in ${formatDuration(expiresIn)} and can only be used once.`), h(Text, { style: fineStyle }, "If you didn't ask to sign in, you can ignore this email."), h(Footer, null)))));
@@ -0,0 +1,13 @@
1
+ /**
2
+ * 🚨 **Rewritten, not ported.** `rngs.io`'s `Brand.tsx` imports `$app/paths`
3
+ * and `$lib/utils/links` to build absolute URLs for two PNGs. Neither the
4
+ * aliases nor the assets exist inside a published npm package, so this is
5
+ * type instead of images: an eyebrow, the app's own name, and the orange rule
6
+ * {@link CentreCard} draws. No asset to host, nothing to break when an app's
7
+ * `assets` path changes, and nothing to load before the email reads.
8
+ */
9
+ export declare const Brand: ({ appName }: {
10
+ appName: string;
11
+ }) => import("react").FunctionComponentElement<Readonly<Omit<import("react").DetailedHTMLProps<import("react").TableHTMLAttributes<HTMLTableElement>, HTMLTableElement>, "ref">> & {
12
+ tdClassName?: string;
13
+ } & import("react").RefAttributes<HTMLTableElement>>;
@@ -0,0 +1,28 @@
1
+ import { createElement as h } from 'react';
2
+ import { Container, Heading, Text } from 'react-email';
3
+ import { colors } from './tokens.js';
4
+ const eyebrowStyle = {
5
+ color: colors.muted,
6
+ fontSize: '11px',
7
+ fontWeight: 700,
8
+ letterSpacing: '0.12em',
9
+ lineHeight: '14px',
10
+ margin: '0 0 6px',
11
+ textTransform: 'uppercase',
12
+ };
13
+ const headingStyle = {
14
+ color: colors.text,
15
+ fontSize: '30px',
16
+ fontWeight: 700,
17
+ lineHeight: 1.1,
18
+ margin: '0',
19
+ };
20
+ /**
21
+ * 🚨 **Rewritten, not ported.** `rngs.io`'s `Brand.tsx` imports `$app/paths`
22
+ * and `$lib/utils/links` to build absolute URLs for two PNGs. Neither the
23
+ * aliases nor the assets exist inside a published npm package, so this is
24
+ * type instead of images: an eyebrow, the app's own name, and the orange rule
25
+ * {@link CentreCard} draws. No asset to host, nothing to break when an app's
26
+ * `assets` path changes, and nothing to load before the email reads.
27
+ */
28
+ export const Brand = ({ appName }) => h(Container, { style: { textAlign: 'left' } }, h(Text, { style: eyebrowStyle }, 'Reuters Graphics'), h(Heading, { as: 'h1', style: headingStyle }, appName));
@@ -0,0 +1,10 @@
1
+ import type { ReactNode } from 'react';
2
+ /**
3
+ * The dark card. Ported from `rngs.io`, plus the Reuters-orange top rule that
4
+ * replaces the wordmark image this package cannot ship.
5
+ */
6
+ export declare const CentreCard: ({ children }: {
7
+ children: ReactNode;
8
+ }) => import("react").FunctionComponentElement<Readonly<Omit<import("react").DetailedHTMLProps<import("react").TableHTMLAttributes<HTMLTableElement>, HTMLTableElement>, "ref">> & {
9
+ tdClassName?: string;
10
+ } & import("react").RefAttributes<HTMLTableElement>>;
@@ -0,0 +1,22 @@
1
+ import { createElement as h } from 'react';
2
+ import { Container, Section } from 'react-email';
3
+ import { colors, fontFamily } from './tokens.js';
4
+ const sectionStyle = {
5
+ backgroundColor: colors.page,
6
+ };
7
+ const containerStyle = {
8
+ backgroundColor: colors.card,
9
+ color: colors.text,
10
+ fontFamily,
11
+ margin: '20px auto',
12
+ padding: '32px 20px 10px',
13
+ borderRadius: '4px',
14
+ borderTop: `3px solid ${colors.reuters}`,
15
+ boxShadow: '0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23)',
16
+ maxWidth: '600px',
17
+ };
18
+ /**
19
+ * The dark card. Ported from `rngs.io`, plus the Reuters-orange top rule that
20
+ * replaces the wordmark image this package cannot ship.
21
+ */
22
+ export const CentreCard = ({ children }) => h(Section, { style: sectionStyle }, h(Container, { style: containerStyle }, children));
@@ -0,0 +1,7 @@
1
+ import type { ReactNode } from 'react';
2
+ /** The narrow measure inside the card. Ported from `rngs.io` unchanged. */
3
+ export declare const CentreWell: ({ children }: {
4
+ children: ReactNode;
5
+ }) => import("react").FunctionComponentElement<Readonly<Omit<import("react").DetailedHTMLProps<import("react").TableHTMLAttributes<HTMLTableElement>, HTMLTableElement>, "ref">> & {
6
+ tdClassName?: string;
7
+ } & import("react").RefAttributes<HTMLTableElement>>;
@@ -0,0 +1,9 @@
1
+ import { createElement as h } from 'react';
2
+ import { Container } from 'react-email';
3
+ const style = {
4
+ maxWidth: '380px',
5
+ margin: '0 auto',
6
+ textAlign: 'left',
7
+ };
8
+ /** The narrow measure inside the card. Ported from `rngs.io` unchanged. */
9
+ export const CentreWell = ({ children }) => h(Container, { style }, children);
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Ported from `rngs.io`, with the copy generalised — the original named
3
+ * RNGS.io, and this ships to every app.
4
+ */
5
+ export declare const Footer: () => import("react").FunctionComponentElement<Readonly<Omit<import("react").DetailedHTMLProps<import("react").TableHTMLAttributes<HTMLTableElement>, HTMLTableElement>, "ref">> & {
6
+ tdClassName?: string;
7
+ } & import("react").RefAttributes<HTMLTableElement>>;
@@ -0,0 +1,26 @@
1
+ import { createElement as h } from 'react';
2
+ import { Container, Hr, Link, Text } from 'react-email';
3
+ import { colors, supportEmail } from './tokens.js';
4
+ const containerStyle = {
5
+ margin: '32px auto 0',
6
+ textAlign: 'left',
7
+ };
8
+ const hrStyle = {
9
+ borderColor: colors.rule,
10
+ margin: '0 0 16px',
11
+ };
12
+ const textStyle = {
13
+ color: colors.muted,
14
+ fontSize: '12px',
15
+ lineHeight: '16px',
16
+ margin: '0 0 20px',
17
+ };
18
+ const linkStyle = {
19
+ color: colors.text,
20
+ fontWeight: 700,
21
+ };
22
+ /**
23
+ * Ported from `rngs.io`, with the copy generalised — the original named
24
+ * RNGS.io, and this ships to every app.
25
+ */
26
+ export const Footer = () => h(Container, { style: containerStyle }, h(Hr, { style: hrStyle }), h(Text, { style: textStyle }, 'An automated message from newsroom tools built by the graphics team at Thomson Reuters. Questions to ', h(Link, { href: `mailto:${supportEmail}`, style: linkStyle }, supportEmail), '.'));
@@ -0,0 +1,10 @@
1
+ export interface HeadProps {
2
+ title: string;
3
+ description: string;
4
+ }
5
+ /**
6
+ * Ported from `rngs.io/src/lib/emails/_components/Head.tsx`, with one fix: the
7
+ * original wrote `background-color: '#323944'` — quotes and all — inside a
8
+ * `<style>` block, which is not valid CSS and so was silently dropped.
9
+ */
10
+ export declare const Head: ({ title, description }: HeadProps) => import("react").FunctionComponentElement<Readonly<Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLHeadElement>, HTMLHeadElement>, "ref">> & import("react").RefAttributes<HTMLHeadElement>>;
@@ -0,0 +1,19 @@
1
+ import { createElement as h } from 'react';
2
+ import { Head as ReactEmailHead } from 'react-email';
3
+ import { colors, fontFamily } from './tokens.js';
4
+ /**
5
+ * Ported from `rngs.io/src/lib/emails/_components/Head.tsx`, with one fix: the
6
+ * original wrote `background-color: '#323944'` — quotes and all — inside a
7
+ * `<style>` block, which is not valid CSS and so was silently dropped.
8
+ */
9
+ export const Head = ({ title, description }) => h(ReactEmailHead, null, h('title', { key: 'title' }, title), h('meta', {
10
+ key: 'description',
11
+ name: 'description',
12
+ content: description,
13
+ }), h('style', { key: 'style' }, `@import url('https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;700&display=swap');
14
+ body {
15
+ background-color: ${colors.page};
16
+ font-family: ${fontFamily};
17
+ margin: 0;
18
+ padding: 0;
19
+ }`));
@@ -0,0 +1,8 @@
1
+ import type { ReactNode } from 'react';
2
+ /** Ported from `rngs.io` — the one thing in the email anybody clicks. */
3
+ export declare const OpenButton: ({ href, children, }: {
4
+ href: string;
5
+ children: ReactNode;
6
+ }) => import("react").FunctionComponentElement<Readonly<Omit<import("react").DetailedHTMLProps<import("react").TableHTMLAttributes<HTMLTableElement>, HTMLTableElement>, "ref">> & {
7
+ tdClassName?: string;
8
+ } & import("react").RefAttributes<HTMLTableElement>>;
@@ -0,0 +1,21 @@
1
+ import { createElement as h } from 'react';
2
+ import { Button, Container } from 'react-email';
3
+ import { colors } from './tokens.js';
4
+ const containerStyle = {
5
+ margin: '24px auto',
6
+ };
7
+ const buttonStyle = {
8
+ backgroundColor: colors.buttonBg,
9
+ color: colors.text,
10
+ borderRadius: '3px',
11
+ border: `1px solid ${colors.buttonBorder}`,
12
+ fontSize: '18px',
13
+ fontWeight: 700,
14
+ textDecoration: 'none',
15
+ textAlign: 'center',
16
+ display: 'block',
17
+ padding: '12px 32px',
18
+ margin: '0 auto',
19
+ };
20
+ /** Ported from `rngs.io` — the one thing in the email anybody clicks. */
21
+ export const OpenButton = ({ href, children, }) => h(Container, { style: containerStyle }, h(Button, { href, style: buttonStyle }, children));
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Brand components for the sign-in email, ported from
3
+ * `rngs.io/src/lib/emails/_components/`.
4
+ *
5
+ * Not in the package's `exports` map, so not public API — a consumer cannot
6
+ * import these, and they can change without a major.
7
+ */
8
+ export { Brand } from './Brand.js';
9
+ export { CentreCard } from './CentreCard.js';
10
+ export { CentreWell } from './CentreWell.js';
11
+ export { Footer } from './Footer.js';
12
+ export { Head } from './Head.js';
13
+ export { OpenButton } from './OpenButton.js';
14
+ export { colors, fontFamily, supportEmail } from './tokens.js';