@binary1702/auth 0.2.4 → 0.3.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 (49) hide show
  1. package/AGENTS.md +139 -0
  2. package/CHANGELOG.md +31 -0
  3. package/INSTALL.md +30 -17
  4. package/PROMPT.md +325 -37
  5. package/README.md +293 -56
  6. package/dist/cli.js +255 -22
  7. package/dist/core/admin-html.d.ts +26 -0
  8. package/dist/core/admin-html.js +252 -0
  9. package/dist/core/admin.d.ts +26 -0
  10. package/dist/core/admin.js +48 -0
  11. package/dist/core/config.d.ts +8 -1
  12. package/dist/core/config.js +20 -6
  13. package/dist/core/db.d.ts +138 -7
  14. package/dist/core/db.js +406 -47
  15. package/dist/core/email.d.ts +49 -0
  16. package/dist/core/email.js +213 -0
  17. package/dist/core/guard.d.ts +49 -0
  18. package/dist/core/guard.js +103 -0
  19. package/dist/core/html.d.ts +36 -0
  20. package/dist/core/html.js +106 -25
  21. package/dist/core/options.d.ts +76 -0
  22. package/dist/core/options.js +310 -0
  23. package/dist/core/org.d.ts +34 -0
  24. package/dist/core/org.js +55 -0
  25. package/dist/core/password.d.ts +16 -0
  26. package/dist/core/password.js +41 -12
  27. package/dist/core/rate.d.ts +26 -0
  28. package/dist/core/rate.js +70 -0
  29. package/dist/core/reset.d.ts +9 -0
  30. package/dist/core/reset.js +31 -0
  31. package/dist/core/session.d.ts +23 -2
  32. package/dist/core/session.js +45 -7
  33. package/dist/core/signup.d.ts +9 -6
  34. package/dist/core/signup.js +26 -15
  35. package/dist/core/tokens.d.ts +29 -0
  36. package/dist/core/tokens.js +64 -0
  37. package/dist/core/verify.d.ts +8 -0
  38. package/dist/core/verify.js +33 -0
  39. package/dist/core/yaml.d.ts +10 -0
  40. package/dist/core/yaml.js +85 -0
  41. package/dist/index.d.ts +17 -6
  42. package/dist/index.js +17 -6
  43. package/dist/next/index.d.ts +11 -4
  44. package/dist/next/index.js +455 -51
  45. package/dist/next/proxy.d.ts +2 -1
  46. package/dist/next/proxy.js +16 -7
  47. package/dist/react/index.d.ts +18 -0
  48. package/dist/react/index.js +55 -0
  49. package/package.json +16 -3
@@ -0,0 +1,213 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // Email. One function, no dependency: everything goes over fetch.
3
+ //
4
+ // gmail email.gmail.user + clientEmail + privateKey (a service
5
+ // account; Workspace: domain-wide delegation) or
6
+ // clientId + clientSecret + refreshToken (OAuth)
7
+ // resend email.resend.apiKey + email.from
8
+ // console neither, not production: print the message
9
+ // none neither, production: throw at the first send
10
+ //
11
+ // (b17_auth.yaml keys; the AUTH_GMAIL_* / AUTH_RESEND_* env vars map
12
+ // onto them, see core/options.ts.)
13
+ //
14
+ // Gmail is two requests: a token from oauth2.googleapis.com (a signed
15
+ // JWT assertion for a service account, a refresh grant otherwise) and
16
+ // a POST of the raw RFC 2822 message to gmail.googleapis.com. Tokens
17
+ // are cached in-process until a minute before they expire.
18
+ //
19
+ // The decision and the message building are pure; fetch is injected.
20
+ // ─────────────────────────────────────────────────────────────
21
+ import { SignJWT, importPKCS8 } from "jose";
22
+ import { options } from "./options.js";
23
+ export const RESEND_URL = "https://api.resend.com/emails";
24
+ export const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
25
+ export const GMAIL_SEND_URL = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send";
26
+ export const GMAIL_SCOPE = "https://www.googleapis.com/auth/gmail.send";
27
+ export const EMAIL_NOT_CONFIGURED = "Email is not configured. In b17_auth.yaml set email.gmail (user + clientEmail + privateKey) or email.resend (apiKey) + email.from (run: npx @binary1702/auth).";
28
+ function current() {
29
+ const o = options();
30
+ return { ...o.email, production: o.production };
31
+ }
32
+ // ── decision ──────────────────────────────────────────────────
33
+ function gmailConfigured(cfg) {
34
+ const g = cfg.gmail;
35
+ if (!g?.user)
36
+ return false;
37
+ if (g.clientEmail && g.privateKey)
38
+ return true;
39
+ return Boolean(g.clientId && g.clientSecret && g.refreshToken);
40
+ }
41
+ /** Which way a message goes. Gmail wins when both are configured. Pure. */
42
+ export function emailTransport(cfg) {
43
+ if (gmailConfigured(cfg))
44
+ return "gmail";
45
+ if (cfg.resend?.apiKey && cfg.from)
46
+ return "resend";
47
+ return cfg.production ? "none" : "console";
48
+ }
49
+ // ── addresses ─────────────────────────────────────────────────
50
+ const b64 = (s) => Buffer.from(s, "utf8").toString("base64");
51
+ /** RFC 2047 for a header that may hold non-ASCII (a subject with an accent) */
52
+ export function encodeHeader(value) {
53
+ return /^[\x20-\x7e]*$/.test(value) ? value : `=?UTF-8?B?${b64(value)}?=`;
54
+ }
55
+ /**
56
+ * A From/To value as the header needs it. `Name <addr>` keeps the name
57
+ * quoted when it holds characters that mean something in a header
58
+ * (parentheses, commas, colons, …) and RFC 2047-encoded when it is not
59
+ * ASCII. A bare address passes through. Pure.
60
+ */
61
+ export function formatAddress(value) {
62
+ const m = value.trim().match(/^(.*?)\s*<([^<>]+)>$/);
63
+ if (!m)
64
+ return value.trim();
65
+ const addr = m[2].trim();
66
+ let name = m[1].trim().replace(/^"(.*)"$/, "$1");
67
+ if (!name)
68
+ return addr;
69
+ if (!/^[\x20-\x7e]*$/.test(name))
70
+ name = encodeHeader(name);
71
+ else if (/[()<>\[\]:;@\\,."]/.test(name))
72
+ name = `"${name.replace(/(["\\])/g, "\\$1")}"`;
73
+ return `${name} <${addr}>`;
74
+ }
75
+ // ── resend ────────────────────────────────────────────────────
76
+ /** The request Resend expects. Pure. */
77
+ export function resendRequest(msg, cfg) {
78
+ return {
79
+ url: RESEND_URL,
80
+ init: {
81
+ method: "POST",
82
+ headers: { authorization: `Bearer ${cfg.resend?.apiKey}`, "content-type": "application/json" },
83
+ body: JSON.stringify({ from: cfg.from, to: [msg.to], subject: msg.subject, text: msg.text, html: msg.html, reply_to: cfg.replyTo }),
84
+ },
85
+ };
86
+ }
87
+ // ── gmail: the message ────────────────────────────────────────
88
+ /** The raw RFC 2822 message Gmail wants, base64url-encoded. Pure. */
89
+ export function gmailRaw(msg, from, replyTo) {
90
+ const crlf = (s) => s.replace(/\r?\n/g, "\r\n");
91
+ const head = [`From: ${formatAddress(from)}`, `To: ${formatAddress(msg.to)}`];
92
+ if (replyTo)
93
+ head.push(`Reply-To: ${formatAddress(replyTo)}`);
94
+ head.push(`Subject: ${encodeHeader(msg.subject)}`, "MIME-Version: 1.0");
95
+ let body;
96
+ if (msg.html) {
97
+ const boundary = `b_${Buffer.from(msg.subject + msg.to).toString("hex").slice(0, 24)}`;
98
+ head.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
99
+ body = [
100
+ `--${boundary}`,
101
+ "Content-Type: text/plain; charset=UTF-8",
102
+ "Content-Transfer-Encoding: base64",
103
+ "",
104
+ b64(crlf(msg.text)),
105
+ `--${boundary}`,
106
+ "Content-Type: text/html; charset=UTF-8",
107
+ "Content-Transfer-Encoding: base64",
108
+ "",
109
+ b64(crlf(msg.html)),
110
+ `--${boundary}--`,
111
+ ].join("\r\n");
112
+ }
113
+ else {
114
+ head.push("Content-Type: text/plain; charset=UTF-8", "Content-Transfer-Encoding: base64");
115
+ body = b64(crlf(msg.text));
116
+ }
117
+ return Buffer.from(head.join("\r\n") + "\r\n\r\n" + body, "utf8").toString("base64url");
118
+ }
119
+ /** The service account's email and PEM key (client_email and private_key in the JSON Google hands out) */
120
+ export function serviceAccount(gmail) {
121
+ if (!gmail.clientEmail || !gmail.privateKey) {
122
+ throw new Error("email.gmail needs clientEmail and privateKey (the service account's client_email and private_key)");
123
+ }
124
+ // .env files tend to keep the key's newlines as the two characters \ n
125
+ const key = gmail.privateKey.replace(/\\n/g, "\n").trim();
126
+ if (!key.includes("-----BEGIN") || !key.includes("PRIVATE KEY-----")) {
127
+ throw new Error("email.gmail.privateKey does not look like a PEM private key (-----BEGIN PRIVATE KEY----- …)");
128
+ }
129
+ return { email: gmail.clientEmail, key };
130
+ }
131
+ /** The signed assertion for a service account acting as the mailbox user (domain-wide delegation). */
132
+ export async function serviceAccountAssertion(gmail, now = new Date()) {
133
+ const sa = serviceAccount(gmail);
134
+ const iat = Math.floor(now.getTime() / 1000);
135
+ return new SignJWT({ scope: GMAIL_SCOPE })
136
+ .setProtectedHeader({ alg: "RS256", typ: "JWT" })
137
+ .setIssuer(sa.email)
138
+ .setSubject(gmail.user)
139
+ .setAudience(GOOGLE_TOKEN_URL)
140
+ .setIssuedAt(iat)
141
+ .setExpirationTime(iat + 3600)
142
+ .sign(await importPKCS8(sa.key, "RS256"));
143
+ }
144
+ /** The token request body, either grant. */
145
+ export async function gmailTokenRequest(gmail) {
146
+ if (gmail.clientEmail && gmail.privateKey) {
147
+ return new URLSearchParams({
148
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
149
+ assertion: await serviceAccountAssertion(gmail),
150
+ });
151
+ }
152
+ return new URLSearchParams({
153
+ grant_type: "refresh_token",
154
+ client_id: gmail.clientId,
155
+ client_secret: gmail.clientSecret,
156
+ refresh_token: gmail.refreshToken,
157
+ });
158
+ }
159
+ const tokens = new Map();
160
+ async function gmailToken(gmail, fetchFn) {
161
+ const cached = tokens.get(gmail.user);
162
+ if (cached && cached.expiresAt - Date.now() > 60_000)
163
+ return cached.token;
164
+ const res = await fetchFn(GOOGLE_TOKEN_URL, {
165
+ method: "POST",
166
+ headers: { "content-type": "application/x-www-form-urlencoded" },
167
+ body: await gmailTokenRequest(gmail),
168
+ });
169
+ const data = (await res.json().catch(() => ({})));
170
+ if (!res.ok || !data.access_token) {
171
+ throw new Error(`Google would not issue a Gmail token (${res.status}): ${data.error ?? ""} ${data.error_description ?? ""}`.trim());
172
+ }
173
+ tokens.set(gmail.user, { token: data.access_token, expiresAt: Date.now() + (data.expires_in ?? 3600) * 1000 });
174
+ return data.access_token;
175
+ }
176
+ /** Forget cached tokens (tests, or after rotating credentials). */
177
+ export function resetGmailTokens() {
178
+ tokens.clear();
179
+ }
180
+ // ── send ──────────────────────────────────────────────────────
181
+ export async function sendEmail(msg, cfg = current(), fetchFn = fetch, log = console.log) {
182
+ switch (emailTransport(cfg)) {
183
+ case "none":
184
+ throw new Error(EMAIL_NOT_CONFIGURED);
185
+ case "console":
186
+ log(["", `[auth] email to ${msg.to}`, `[auth] subject: ${msg.subject}`, msg.text.replace(/^/gm, "[auth] "), ""].join("\n"));
187
+ return;
188
+ case "resend": {
189
+ const { url, init } = resendRequest(msg, cfg);
190
+ const res = await fetchFn(url, init);
191
+ if (!res.ok) {
192
+ const detail = await res.text().catch(() => "");
193
+ throw new Error(`Resend refused the email (${res.status}): ${detail.slice(0, 300)}`);
194
+ }
195
+ return;
196
+ }
197
+ case "gmail": {
198
+ const gmail = cfg.gmail;
199
+ const token = await gmailToken(gmail, fetchFn);
200
+ const res = await fetchFn(GMAIL_SEND_URL, {
201
+ method: "POST",
202
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
203
+ body: JSON.stringify({ raw: gmailRaw(msg, cfg.from || gmail.user, cfg.replyTo) }),
204
+ });
205
+ if (!res.ok) {
206
+ if (res.status === 401)
207
+ tokens.delete(gmail.user); // stale token: next send fetches a new one
208
+ const detail = await res.text().catch(() => "");
209
+ throw new Error(`Gmail refused the email (${res.status}): ${detail.slice(0, 300)}`);
210
+ }
211
+ }
212
+ }
213
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * A post-login destination, reduced to a same-origin path.
3
+ * Resolves the value the way the browser will (`/\evil.com` and
4
+ * `/\t/evil.com` both become `https://evil.com/`) and keeps it only
5
+ * if it stays on `base`'s origin.
6
+ */
7
+ export declare function safeNext(value: string | null | undefined, base: string): string;
8
+ /**
9
+ * The proxy runs on everything except Next's build output; this
10
+ * function decides the rest. Kept in sync with the CLI's proxy.ts.
11
+ */
12
+ export declare const MATCHER: string[];
13
+ /**
14
+ * Does a configured pattern cover this path?
15
+ * /pricing that path only
16
+ * /blog/* /blog, /blog/ and anything below (never /blogs)
17
+ */
18
+ export declare function matchesPattern(pathname: string, pattern: string): boolean;
19
+ export type AccessRules = {
20
+ public?: readonly string[];
21
+ protected?: readonly string[];
22
+ };
23
+ /**
24
+ * Paths reachable without a session. The auth pages and Next internals
25
+ * always are. Then one of two modes from b17_auth.yaml:
26
+ *
27
+ * protected: /app/* /api/* only these need a session; everything else is open
28
+ * public: / /pricing everything needs a session except these, static
29
+ * files by extension, and never anything under /api/
30
+ * (so /api/export.csv stays guarded unless listed)
31
+ *
32
+ * Neither set: the second mode with an empty list, i.e. everything guarded.
33
+ */
34
+ export declare function isPublic(pathname: string, rules?: AccessRules | readonly string[]): boolean;
35
+ /**
36
+ * Equality that takes the same time whatever the inputs, for the invite
37
+ * code. Hashing both sides first means a length difference leaks nothing.
38
+ */
39
+ export declare function safeEqual(a: string, b: string): boolean;
40
+ /**
41
+ * True unless the request provably came from another site.
42
+ * Sec-Fetch-Site sent by every modern browser: same-origin or none
43
+ * (typed URL, bookmark) pass, anything else fails
44
+ * Origin older browsers: must match the host served
45
+ * neither not a browser (curl, server code): passes
46
+ */
47
+ export declare function isSameOrigin(headers: {
48
+ get(name: string): string | null;
49
+ }): boolean;
@@ -0,0 +1,103 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // Request guards. Pure functions over strings and headers, so the
3
+ // security rules can be tested and read without Next or a database.
4
+ //
5
+ // safeNext() where to go after sign-in: same-origin path, or "/"
6
+ // isPublic() which paths the proxy lets through without a cookie
7
+ // isSameOrigin() refuse cross-site POSTs (login CSRF)
8
+ // safeEqual() constant-time string compare (invite code)
9
+ // ─────────────────────────────────────────────────────────────
10
+ import { createHash, timingSafeEqual } from "node:crypto";
11
+ /**
12
+ * A post-login destination, reduced to a same-origin path.
13
+ * Resolves the value the way the browser will (`/\evil.com` and
14
+ * `/\t/evil.com` both become `https://evil.com/`) and keeps it only
15
+ * if it stays on `base`'s origin.
16
+ */
17
+ export function safeNext(value, base) {
18
+ if (!value)
19
+ return "/";
20
+ try {
21
+ const origin = new URL(base).origin;
22
+ const url = new URL(value, origin);
23
+ if (url.origin !== origin)
24
+ return "/";
25
+ return url.pathname + url.search + url.hash;
26
+ }
27
+ catch {
28
+ return "/";
29
+ }
30
+ }
31
+ /**
32
+ * The proxy runs on everything except Next's build output; this
33
+ * function decides the rest. Kept in sync with the CLI's proxy.ts.
34
+ */
35
+ export const MATCHER = ["/((?!_next/static|_next/image|favicon.ico).*)"];
36
+ const STATIC_FILE = /\.(?:png|jpe?g|gif|svg|ico|webp|avif|css|js|map|woff2?|ttf|otf|txt|xml|webmanifest)$/i;
37
+ /**
38
+ * Does a configured pattern cover this path?
39
+ * /pricing that path only
40
+ * /blog/* /blog, /blog/ and anything below (never /blogs)
41
+ */
42
+ export function matchesPattern(pathname, pattern) {
43
+ if (pattern.endsWith("/*")) {
44
+ const base = pattern.slice(0, -2) || "/";
45
+ return pathname === base || pathname === base + "/" || pathname.startsWith(base === "/" ? "/" : base + "/");
46
+ }
47
+ return pathname === pattern;
48
+ }
49
+ /**
50
+ * Paths reachable without a session. The auth pages and Next internals
51
+ * always are. Then one of two modes from b17_auth.yaml:
52
+ *
53
+ * protected: /app/* /api/* only these need a session; everything else is open
54
+ * public: / /pricing everything needs a session except these, static
55
+ * files by extension, and never anything under /api/
56
+ * (so /api/export.csv stays guarded unless listed)
57
+ *
58
+ * Neither set: the second mode with an empty list, i.e. everything guarded.
59
+ */
60
+ export function isPublic(pathname, rules = {}) {
61
+ const r = Array.isArray(rules) ? { public: rules } : rules;
62
+ if (/^\/auth(\/|$)/.test(pathname))
63
+ return true;
64
+ if (pathname.startsWith("/_next/"))
65
+ return true;
66
+ if (r.protected?.length)
67
+ return !r.protected.some((p) => matchesPattern(pathname, p));
68
+ if (r.public?.some((p) => matchesPattern(pathname, p)))
69
+ return true;
70
+ if (pathname.startsWith("/api/"))
71
+ return false;
72
+ return STATIC_FILE.test(pathname);
73
+ }
74
+ /**
75
+ * Equality that takes the same time whatever the inputs, for the invite
76
+ * code. Hashing both sides first means a length difference leaks nothing.
77
+ */
78
+ export function safeEqual(a, b) {
79
+ const h = (s) => createHash("sha256").update(s, "utf8").digest();
80
+ return timingSafeEqual(h(a), h(b));
81
+ }
82
+ /**
83
+ * True unless the request provably came from another site.
84
+ * Sec-Fetch-Site sent by every modern browser: same-origin or none
85
+ * (typed URL, bookmark) pass, anything else fails
86
+ * Origin older browsers: must match the host served
87
+ * neither not a browser (curl, server code): passes
88
+ */
89
+ export function isSameOrigin(headers) {
90
+ const site = headers.get("sec-fetch-site");
91
+ if (site)
92
+ return site === "same-origin" || site === "none";
93
+ const origin = headers.get("origin");
94
+ if (!origin)
95
+ return true;
96
+ const host = (headers.get("x-forwarded-host") ?? headers.get("host"))?.split(",")[0].trim();
97
+ try {
98
+ return new URL(origin).host === host;
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ }
@@ -1,8 +1,34 @@
1
+ export declare const escape: (s: string) => string;
2
+ /**
3
+ * Headers every auth page is served with. Pure.
4
+ * no-store a sign-in form or an admin page is never cached, by a browser or a CDN
5
+ * X-Frame-Options never shown inside another site's frame (clickjacking)
6
+ * CSP no scripts at all unless the page brings its own nonce; styles and the
7
+ * data: favicon are inline; forms post only to this origin
8
+ */
9
+ export declare function securityHeaders(scriptNonce?: string): Record<string, string>;
10
+ export declare function setSiteName(name: string | undefined): void;
11
+ /** "Didn't get the email?" A second form, POSTing to /auth/verify/resend. */
12
+ export declare function resendForm(email?: string): string;
1
13
  export declare function signInPage(opts?: {
2
14
  error?: string;
15
+ notice?: string;
3
16
  next?: string;
4
17
  title?: string;
5
18
  canSignUp?: boolean;
19
+ resend?: boolean;
20
+ email?: string;
21
+ }): string;
22
+ /** Ask for the email to send a reset link to. With `sent`, show the (always identical) confirmation instead. */
23
+ export declare function forgotPage(opts?: {
24
+ sent?: boolean;
25
+ error?: string;
26
+ message?: string;
27
+ }): string;
28
+ /** The form behind the emailed link: a new password, the token hidden. */
29
+ export declare function resetPage(opts: {
30
+ token: string;
31
+ error?: string;
6
32
  }): string;
7
33
  export declare function signUpPage(opts?: {
8
34
  error?: string;
@@ -11,3 +37,13 @@ export declare function signUpPage(opts?: {
11
37
  inviteRequired?: boolean;
12
38
  first?: boolean;
13
39
  }): string;
40
+ /** A page with no form: a result, an error, a "check your email". */
41
+ export declare function messagePage(opts: {
42
+ title: string;
43
+ message: string;
44
+ link?: {
45
+ href: string;
46
+ text: string;
47
+ };
48
+ after?: string;
49
+ }): string;
package/dist/core/html.js CHANGED
@@ -1,66 +1,142 @@
1
1
  // ─────────────────────────────────────────────────────────────
2
- // The sign-in and sign-up pages. Plain HTML served by the route
3
- // handler, so the consumer never builds a form or a page.
2
+ // The auth pages. Plain HTML served by the route handler, so the
3
+ // consumer never builds a form or a page. Every untrusted value goes
4
+ // through escape().
4
5
  // ─────────────────────────────────────────────────────────────
5
6
  import { SIGN_IN_PATH, SIGN_UP_PATH } from "./config.js";
6
- const escape = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
7
+ import { VERIFY_PATH } from "./verify.js";
8
+ import { FORGOT_PATH, RESET_PATH } from "./reset.js";
9
+ import { PASSWORD_MAX, PASSWORD_MIN } from "./signup.js";
10
+ export const escape = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
11
+ /**
12
+ * Headers every auth page is served with. Pure.
13
+ * no-store a sign-in form or an admin page is never cached, by a browser or a CDN
14
+ * X-Frame-Options never shown inside another site's frame (clickjacking)
15
+ * CSP no scripts at all unless the page brings its own nonce; styles and the
16
+ * data: favicon are inline; forms post only to this origin
17
+ */
18
+ export function securityHeaders(scriptNonce) {
19
+ const script = scriptNonce ? `script-src 'nonce-${scriptNonce}'` : "script-src 'none'";
20
+ return {
21
+ "cache-control": "no-store",
22
+ "x-frame-options": "DENY",
23
+ "content-security-policy": `default-src 'none'; ${script}; style-src 'unsafe-inline'; img-src data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`,
24
+ "referrer-policy": "same-origin",
25
+ };
26
+ }
7
27
  const STYLE = `
8
- :root { color-scheme: light dark; --fg: #111; --bg: #fff; --line: #ddd; --muted: #666; --err: #c0392b; }
9
- @media (prefers-color-scheme: dark) { :root { --fg: #eee; --bg: #111; --line: #333; --muted: #999; --err: #ff6b6b; } }
28
+ :root { color-scheme: light dark; --fg: #111; --bg: #fff; --line: #ddd; --muted: #666; --err: #c0392b; --ok: #1e8449; }
29
+ @media (prefers-color-scheme: dark) { :root { --fg: #eee; --bg: #111; --line: #333; --muted: #999; --err: #ff6b6b; --ok: #58d68d; } }
10
30
  * { box-sizing: border-box; }
11
31
  body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: var(--bg); color: var(--fg);
12
32
  font: 15px/1.4 system-ui, -apple-system, sans-serif; }
13
- form { width: 100%; max-width: 320px; padding: 16px; display: grid; gap: 12px; }
33
+ main { width: 100%; max-width: 320px; padding: 16px; display: grid; gap: 12px; }
34
+ form { display: grid; gap: 12px; }
14
35
  h1 { font-size: 18px; font-weight: 600; margin: 0 0 4px; }
36
+ p { margin: 0; }
15
37
  label { display: grid; gap: 4px; font-size: 13px; color: var(--muted); }
16
38
  input { font: inherit; padding: 8px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--fg); }
17
39
  input:focus { outline: 2px solid var(--fg); outline-offset: 1px; }
18
40
  button { font: inherit; font-weight: 500; padding: 9px; border: 0; border-radius: 6px; background: var(--fg); color: var(--bg); cursor: pointer; }
19
- .error { margin: 0; font-size: 13px; color: var(--err); }
20
- .alt { margin: 4px 0 0; font-size: 13px; color: var(--muted); text-align: center; }
41
+ .error { font-size: 13px; color: var(--err); }
42
+ .notice { font-size: 13px; color: var(--ok); }
43
+ .alt { margin-top: 4px; font-size: 13px; color: var(--muted); text-align: center; }
21
44
  .alt a { color: var(--fg); }
45
+ .resend { margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--line); }
46
+ .resend button { background: none; color: var(--fg); border: 1px solid var(--line); }
22
47
  `;
23
- function page(opts) {
24
- const title = escape(opts.title);
25
- const next = escape(opts.next ?? "/");
26
- const error = opts.error ? `<p class="error">${escape(opts.error)}</p>` : "";
48
+ /** The app name for <title>, set by the handlers from b17_auth.yaml `name` */
49
+ let siteName = "";
50
+ export function setSiteName(name) {
51
+ siteName = name ?? "";
52
+ }
53
+ function shell(title, body) {
27
54
  return `<!doctype html>
28
55
  <html lang="en">
29
56
  <head>
30
57
  <meta charset="utf-8">
31
58
  <meta name="viewport" content="width=device-width, initial-scale=1">
32
- <title>${title}</title>
59
+ <title>${escape(siteName ? `${title} · ${siteName}` : title)}</title>
33
60
  <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔒</text></svg>">
34
61
  <style>${STYLE}</style>
35
62
  </head>
36
63
  <body>
37
- <form method="post" action="${opts.action}">
64
+ <main>
65
+ ${body}
66
+ </main>
67
+ </body>
68
+ </html>`;
69
+ }
70
+ function page(opts) {
71
+ const title = escape(opts.title);
72
+ const next = escape(opts.next ?? "/");
73
+ const error = opts.error ? ` <p class="error">${escape(opts.error)}</p>\n` : "";
74
+ const notice = opts.notice ? ` <p class="notice">${escape(opts.notice)}</p>\n` : "";
75
+ return shell(opts.title, `<form method="post" action="${opts.action}">
38
76
  <h1>${title}</h1>
39
- ${error}
40
- ${opts.fields}
41
- <input type="hidden" name="next" value="${next}">
77
+ ${error}${notice}${opts.fields}
78
+ <input type="hidden" name="next" value="${next}">${opts.hidden ?? ""}
42
79
  <button type="submit">${escape(opts.button)}</button>
43
80
  ${opts.alt ?? ""}
44
81
  </form>
45
- </body>
46
- </html>`;
82
+ ${opts.after ?? ""}`);
83
+ }
84
+ const nextQuery = (next) => (next && next !== "/" ? `?next=${encodeURIComponent(next)}` : "");
85
+ /** "Didn't get the email?" A second form, POSTing to /auth/verify/resend. */
86
+ export function resendForm(email) {
87
+ return `<form method="post" action="${VERIFY_PATH}/resend" class="resend">
88
+ <label>Did not get the email? <input name="email" type="email" autocomplete="email" value="${escape(email ?? "")}" required></label>
89
+ <button type="submit">Send the link again</button>
90
+ </form>`;
47
91
  }
48
92
  export function signInPage(opts = {}) {
49
- const next = opts.next && opts.next !== "/" ? `?next=${encodeURIComponent(opts.next)}` : "";
50
93
  return page({
51
94
  title: opts.title ?? "Sign in",
52
95
  action: SIGN_IN_PATH,
53
96
  error: opts.error,
97
+ notice: opts.notice,
54
98
  next: opts.next,
55
99
  button: "Sign in",
56
100
  fields: `
57
- <label>Email <input name="email" type="email" autocomplete="email" autofocus required></label>
101
+ <label>Email <input name="email" type="email" autocomplete="email" autofocus required value="${escape(opts.email ?? "")}"></label>
58
102
  <label>Password <input name="password" type="password" autocomplete="current-password" required></label>`,
59
- alt: opts.canSignUp ? `<p class="alt">No account? <a href="${SIGN_UP_PATH}${next}">Create one</a></p>` : undefined,
103
+ alt: `<p class="alt"><a href="${FORGOT_PATH}">Forgot password?</a>${opts.canSignUp ? ` · No account? <a href="${SIGN_UP_PATH}${nextQuery(opts.next)}">Create one</a>` : ""}</p>`,
104
+ after: opts.resend ? resendForm(opts.email) : undefined,
105
+ });
106
+ }
107
+ /** Ask for the email to send a reset link to. With `sent`, show the (always identical) confirmation instead. */
108
+ export function forgotPage(opts = {}) {
109
+ if (opts.sent) {
110
+ return messagePage({
111
+ title: "Check your email",
112
+ message: opts.message ?? "",
113
+ link: { href: SIGN_IN_PATH, text: "Back to sign in" },
114
+ });
115
+ }
116
+ return page({
117
+ title: "Reset your password",
118
+ action: FORGOT_PATH,
119
+ error: opts.error,
120
+ button: "Send reset link",
121
+ fields: `
122
+ <label>Email <input name="email" type="email" autocomplete="email" autofocus required></label>`,
123
+ alt: `<p class="alt"><a href="${SIGN_IN_PATH}">Back to sign in</a></p>`,
124
+ });
125
+ }
126
+ /** The form behind the emailed link: a new password, the token hidden. */
127
+ export function resetPage(opts) {
128
+ return page({
129
+ title: "Choose a new password",
130
+ action: RESET_PATH,
131
+ error: opts.error,
132
+ button: "Set password",
133
+ hidden: `\n <input type="hidden" name="token" value="${escape(opts.token)}">`,
134
+ fields: `
135
+ <label>New password <input name="password" type="password" autocomplete="new-password" minlength="${PASSWORD_MIN}" maxlength="${PASSWORD_MAX}" autofocus required></label>`,
136
+ alt: `<p class="alt"><a href="${FORGOT_PATH}">Request a new link</a></p>`,
60
137
  });
61
138
  }
62
139
  export function signUpPage(opts = {}) {
63
- const next = opts.next && opts.next !== "/" ? `?next=${encodeURIComponent(opts.next)}` : "";
64
140
  return page({
65
141
  title: opts.title ?? (opts.first ? "Create the first account" : "Create account"),
66
142
  action: SIGN_UP_PATH,
@@ -69,7 +145,12 @@ export function signUpPage(opts = {}) {
69
145
  button: "Create account",
70
146
  fields: `
71
147
  <label>Email <input name="email" type="email" autocomplete="email" autofocus required></label>
72
- <label>Password <input name="password" type="password" autocomplete="new-password" minlength="8" required></label>${opts.inviteRequired ? `\n <label>Invite code <input name="invite" autocomplete="off" required></label>` : ""}`,
73
- alt: opts.first ? undefined : `<p class="alt">Have an account? <a href="${SIGN_IN_PATH}${next}">Sign in</a></p>`,
148
+ <label>Password <input name="password" type="password" autocomplete="new-password" minlength="${PASSWORD_MIN}" maxlength="${PASSWORD_MAX}" required></label>${opts.inviteRequired ? `\n <label>Invite code <input name="invite" autocomplete="off" required></label>` : ""}`,
149
+ alt: opts.first ? undefined : `<p class="alt">Have an account? <a href="${SIGN_IN_PATH}${nextQuery(opts.next)}">Sign in</a></p>`,
74
150
  });
75
151
  }
152
+ /** A page with no form: a result, an error, a "check your email". */
153
+ export function messagePage(opts) {
154
+ const link = opts.link ? `\n<p class="alt"><a href="${escape(opts.link.href)}">${escape(opts.link.text)}</a></p>` : "";
155
+ return shell(opts.title, `<h1>${escape(opts.title)}</h1>\n<p>${escape(opts.message)}</p>${link}\n${opts.after ?? ""}`);
156
+ }