slauthjs 1.0.0 → 2.0.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/src/element.js CHANGED
@@ -1,49 +1,66 @@
1
1
  import { createClient } from './client.js'
2
+ import { MESSAGES, STYLE, escape } from './ui.js'
2
3
 
3
4
  /**
4
- * <slauth-sign-in>: a drop-in sign-in box. Email → 6-digit code → signed in, with
5
- * "Sign in with a passkey" and an offer to add one afterwards. Works in any framework
6
- * (it's a plain custom element) and is themed with CSS variables:
5
+ * <slauth-sign-in>: a drop-in sign-in box. It asks the server what's turned on and
6
+ * offers it: "Continue with GitHub/Google/…", email → 6-digit code, a password, a
7
+ * passkey, then the two-factor code for people who set one up, and an offer to add a
8
+ * passkey afterwards. Works in any framework (it's a plain custom element) and is themed
9
+ * with CSS variables:
7
10
  *
8
11
  * slauth-sign-in {
9
12
  * --slauth-accent: #f59e0b; --slauth-ink: #2b2118; --slauth-bg: #fff;
10
13
  * --slauth-radius: 14px; --slauth-font: inherit;
14
+ * --slauth-accent-ink: #2b2118; (text on the accent; for dark themes)
15
+ * --slauth-error: #c2410c;
11
16
  * }
12
17
  *
13
18
  * Attributes: base-path (default /auth), app-name, redirect (where to go once signed in),
14
- * no-passkeys (email codes only).
19
+ * mode ("sign-up" to start on the password sign-up form), no-passkeys (no passkey
20
+ * button or offer).
15
21
  * Fires `slauth:signed-in` with `{ detail: { user } }`.
16
22
  *
17
23
  * import 'slauthjs/element'
18
24
  * <slauth-sign-in app-name="Planda" redirect="/"></slauth-sign-in>
19
25
  */
20
26
 
21
- const STYLE = `
22
- :host { display: block; font-family: var(--slauth-font, inherit); color: var(--slauth-ink, #2b2118); max-width: 360px; }
23
- .card { background: var(--slauth-bg, #fff); border: 2px solid var(--slauth-ink, #2b2118); border-radius: var(--slauth-radius, 14px); padding: 22px; box-shadow: 4px 4px 0 var(--slauth-ink, #2b2118); }
24
- h2 { margin: 0 0 4px; font-size: 20px; line-height: 1.2; }
25
- p { margin: 0 0 14px; font-size: 14px; opacity: .8; }
26
- label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; }
27
- input { box-sizing: border-box; width: 100%; font: inherit; font-size: 16px; padding: 10px 12px; border: 2px solid var(--slauth-ink, #2b2118); border-radius: calc(var(--slauth-radius, 14px) - 6px); background: transparent; color: inherit; }
28
- input.code { letter-spacing: .4em; text-align: center; font-size: 22px; font-weight: 700; }
29
- input:focus { outline: 3px solid var(--slauth-accent, #f59e0b); outline-offset: 1px; }
30
- button { font: inherit; font-weight: 700; font-size: 15px; cursor: pointer; border-radius: calc(var(--slauth-radius, 14px) - 6px); padding: 10px 14px; width: 100%; margin-top: 12px; }
31
- .primary { background: var(--slauth-accent, #f59e0b); color: var(--slauth-ink, #2b2118); border: 2px solid var(--slauth-ink, #2b2118); }
32
- .primary:active { transform: translate(1px, 1px); }
33
- .ghost { background: transparent; color: inherit; border: 2px dashed currentColor; opacity: .85; }
34
- .link { background: none; border: 0; padding: 4px; width: auto; font-weight: 600; text-decoration: underline; opacity: .75; }
35
- button:disabled { opacity: .5; cursor: default; }
36
- .error { color: #c2410c; font-size: 13px; font-weight: 600; min-height: 18px; margin: 8px 0 0; }
37
- .row { display: flex; justify-content: space-between; margin-top: 8px; }
38
- `
39
-
40
27
  class SlauthSignIn extends HTMLElement {
41
28
  connectedCallback() {
42
29
  this.auth = createClient({ basePath: this.getAttribute('base-path') ?? '/auth' })
43
30
  this.appName = this.getAttribute('app-name') ?? ''
44
- this.state = { step: 'email', email: '', error: '', busy: false }
31
+ this.config = { providers: [], passwords: false, passkeys: true }
32
+ this.state = { step: 'email', mode: this.getAttribute('mode') === 'sign-up' ? 'sign-up' : 'code', email: '', error: '', busy: false, recovery: false }
45
33
  this.attachShadow({ mode: 'open' })
34
+ this.readUrl()
46
35
  this.render()
36
+ this.load()
37
+ }
38
+
39
+ /** Errors and the two-factor step can arrive in the URL, after a provider sends people back. */
40
+ readUrl() {
41
+ const params = new URLSearchParams(window.location.search)
42
+ const error = params.get('slauth_error')
43
+ if (error) this.state.error = MESSAGES[error] ?? 'Something went wrong. Please try again.'
44
+ if (params.get('slauth_step') === 'two-factor') this.state.step = 'two-factor'
45
+ this.afterRedirect = params.get('redirect')
46
+ this.invite = params.get('slauth_invite')
47
+ if (error || params.has('slauth_step')) {
48
+ for (const key of ['slauth_error', 'slauth_step']) params.delete(key)
49
+ const query = params.toString()
50
+ window.history.replaceState(window.history.state, '', window.location.pathname + (query ? `?${query}` : '') + window.location.hash)
51
+ }
52
+ }
53
+
54
+ async load() {
55
+ try {
56
+ const [config, session] = await Promise.all([this.auth.config(), this.auth.session()])
57
+ this.config = config
58
+ if (session.twoFactor === 'pending') this.state.step = 'two-factor'
59
+ if (this.state.mode === 'sign-up' && !config.passwords) this.state.mode = 'code'
60
+ this.render()
61
+ } catch {
62
+ // The box still works with email codes.
63
+ }
47
64
  }
48
65
 
49
66
  set(patch) {
@@ -56,96 +73,181 @@ class SlauthSignIn extends HTMLElement {
56
73
  try {
57
74
  await action()
58
75
  } catch (error) {
59
- this.set({ error: error.message ?? 'Something went wrong.' })
76
+ this.set({ error: MESSAGES[error.code] ?? error.message ?? 'Something went wrong.' })
60
77
  } finally {
61
78
  if (this.isConnected) this.set({ busy: false })
62
79
  }
63
80
  }
64
81
 
65
- done(user) {
82
+ /** After the first step: the two-factor code, the passkey offer, or done. */
83
+ async signedIn(result, { offer = true } = {}) {
84
+ if (result.twoFactor) return this.set({ step: 'two-factor', recovery: false })
85
+ this.user = result.user
86
+ if (offer && this.passkeys()) return this.set({ step: 'offer' })
87
+ return this.done(result.user)
88
+ }
89
+
90
+ async done(user) {
91
+ if (this.invite && this.config.organizations) await this.auth.orgs.accept(this.invite).catch(() => {})
66
92
  // Back to the start, ready for the next time it's shown (e.g. after signing out).
67
- this.state = { step: 'email', email: '', error: '', busy: false }
93
+ this.state = { step: 'email', mode: 'code', email: '', error: '', busy: false, recovery: false }
68
94
  this.render()
69
95
  this.dispatchEvent(new CustomEvent('slauth:signed-in', { detail: { user }, bubbles: true, composed: true }))
70
- const to = this.getAttribute('redirect')
96
+ const to = this.getAttribute('redirect') ?? this.afterRedirect
71
97
  if (to) window.location.assign(to)
72
98
  }
73
99
 
100
+ passkeys() {
101
+ return !this.hasAttribute('no-passkeys') && this.config.passkeys !== false && this.auth.passkey.supported()
102
+ }
103
+
74
104
  render() {
75
- const { step, email, error, busy } = this.state
76
- const passkeys = !this.hasAttribute('no-passkeys') && this.auth.passkey.supported()
77
- const name = this.appName ? ` to ${escape(this.appName)}` : ''
78
- const views = {
79
- email: `
105
+ const { step, mode, email, error, busy, recovery } = this.state
106
+ const { providers = [], passwords } = this.config
107
+ const app = this.appName || (this.config.appName !== 'your app' ? (this.config.appName ?? '') : '')
108
+ const name = app ? ` to ${escape(app)}` : ''
109
+ const off = busy ? 'disabled' : ''
110
+ const redirect = this.getAttribute('redirect') ?? window.location.pathname + window.location.search
111
+ const providerButtons = providers.length
112
+ ? `<div class="providers">${providers.map((p) => `<a class="button" part="provider" href="${escape(this.auth.oauth.url(p.id, { redirect }))}">Continue with ${escape(p.name)}</a>`).join('')}</div><div class="or">or</div>`
113
+ : ''
114
+
115
+ const emailViews = {
116
+ code: `
80
117
  <h2>Sign in${name}</h2>
118
+ ${providerButtons}
81
119
  <p>We'll email you a 6-digit code. No password needed.</p>
82
120
  <form data-on="email">
83
121
  <label for="email">Email</label>
84
122
  <input id="email" type="email" autocomplete="email webauthn" required value="${escape(email)}" />
85
- <button class="primary" ${busy ? 'disabled' : ''}>Email me a code</button>
123
+ <button class="primary" ${off}>Email me a code</button>
124
+ </form>
125
+ ${this.passkeys() ? `<button class="ghost dashed" data-on="passkey" ${off}>Sign in with a passkey</button>` : ''}
126
+ ${passwords ? `<div class="row"><button class="link" data-on="mode-password">Use a password</button><button class="link" data-on="mode-sign-up">Create an account</button></div>` : ''}`,
127
+ password: `
128
+ <h2>Sign in${name}</h2>
129
+ ${providerButtons}
130
+ <form data-on="password">
131
+ <label for="email">Email</label>
132
+ <input id="email" type="email" autocomplete="username webauthn" required value="${escape(email)}" />
133
+ <label for="password">Password</label>
134
+ <input id="password" type="password" autocomplete="current-password" required />
135
+ <button class="primary" ${off}>Sign in</button>
86
136
  </form>
87
- ${passkeys ? `<button class="ghost" data-on="passkey" ${busy ? 'disabled' : ''}>Sign in with a passkey</button>` : ''}`,
137
+ <div class="row"><button class="link" data-on="mode-code">Email me a code instead</button><button class="link" data-on="mode-code">Forgot password?</button></div>`,
138
+ 'sign-up': `
139
+ <h2>Create an account${name}</h2>
140
+ ${providerButtons}
141
+ <form data-on="sign-up">
142
+ <label for="email">Email</label>
143
+ <input id="email" type="email" autocomplete="email" required value="${escape(email)}" />
144
+ <label for="password">Password</label>
145
+ <input id="password" type="password" autocomplete="new-password" minlength="8" required />
146
+ <button class="primary" ${off}>Create account</button>
147
+ </form>
148
+ <div class="row"><button class="link" data-on="mode-password">I have an account</button></div>`,
149
+ }
150
+
151
+ const views = {
152
+ email: emailViews[mode] ?? emailViews.code,
88
153
  code: `
89
154
  <h2>Check your email</h2>
90
- <p>We sent a code to <strong>${escape(email)}</strong>.</p>
155
+ <p>We sent a code to <strong>${escape(email)}</strong>${this.config.magicLinks ? ', with a link you can tap instead' : ''}.</p>
91
156
  <form data-on="code">
92
157
  <label for="code">Code</label>
93
158
  <input id="code" class="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" required />
94
- <button class="primary" ${busy ? 'disabled' : ''}>Sign in</button>
159
+ <button class="primary" ${off}>${mode === 'sign-up' ? 'Create account' : 'Sign in'}</button>
95
160
  </form>
96
161
  <div class="row"><button class="link" data-on="back">Use another email</button><button class="link" data-on="resend">Send again</button></div>`,
162
+ 'two-factor': recovery
163
+ ? `
164
+ <h2>Use a recovery code</h2>
165
+ <p>One of the codes you saved when you turned on two-factor sign-in. Each works once.</p>
166
+ <form data-on="two-factor">
167
+ <label for="code">Recovery code</label>
168
+ <input id="code" autocomplete="off" autocapitalize="off" spellcheck="false" required />
169
+ <button class="primary" ${off}>Continue</button>
170
+ </form>
171
+ <div class="row"><button class="link" data-on="use-app">Use the app instead</button></div>`
172
+ : `
173
+ <h2>Two-factor sign-in</h2>
174
+ <p>Enter the 6-digit code from your authenticator app.</p>
175
+ <form data-on="two-factor">
176
+ <label for="code">Code</label>
177
+ <input id="code" class="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" required />
178
+ <button class="primary" ${off}>Continue</button>
179
+ </form>
180
+ <div class="row"><button class="link" data-on="use-recovery">Lost your phone?</button><button class="link" data-on="back">Start over</button></div>`,
97
181
  offer: `
98
182
  <h2>You're in 🦥</h2>
99
183
  <p>Add a passkey and next time it's just Face ID, Touch ID or your PIN.</p>
100
- <button class="primary" data-on="add" ${busy ? 'disabled' : ''}>Add a passkey</button>
184
+ <button class="primary" data-on="add" ${off}>Add a passkey</button>
101
185
  <button class="link" data-on="skip">Not now</button>`,
102
186
  }
103
187
  this.shadowRoot.innerHTML = `<style>${STYLE}</style><div class="card" part="card">${views[step]}<p class="error" role="alert">${escape(error)}</p></div>`
104
188
 
105
189
  const on = (key, handler) => {
106
- const el = this.shadowRoot.querySelector(`[data-on="${key}"]`)
107
- el?.addEventListener(el.tagName === 'FORM' ? 'submit' : 'click', (event) => {
108
- event.preventDefault()
109
- handler()
110
- })
190
+ for (const el of this.shadowRoot.querySelectorAll(`[data-on="${key}"]`)) {
191
+ el.addEventListener(el.tagName === 'FORM' ? 'submit' : 'click', (event) => {
192
+ event.preventDefault()
193
+ handler()
194
+ })
195
+ }
111
196
  }
197
+ const value = (id) => this.shadowRoot.getElementById(id)?.value ?? ''
112
198
  // Read inputs before run(): it re-renders the form, which would empty them.
113
199
  on('email', () => {
114
- const value = this.shadowRoot.getElementById('email').value.trim()
115
- this.set({ email: value })
200
+ const typed = value('email').trim()
201
+ this.set({ email: typed })
116
202
  return this.run(async () => {
117
- await this.auth.email.start(value)
203
+ await this.auth.email.start(typed, { redirect: this.getAttribute('redirect') ?? undefined })
118
204
  this.set({ step: 'code' })
119
205
  this.shadowRoot.getElementById('code')?.focus()
120
206
  })
121
207
  })
122
- on('code', () => {
123
- const code = this.shadowRoot.getElementById('code').value
208
+ on('password', () => {
209
+ const typed = value('email').trim()
210
+ const password = value('password')
211
+ this.set({ email: typed })
212
+ return this.run(async () => this.signedIn(await this.auth.password.signIn(typed, password)))
213
+ })
214
+ on('sign-up', () => {
215
+ const typed = value('email').trim()
216
+ const password = value('password')
217
+ this.set({ email: typed })
124
218
  return this.run(async () => {
125
- const { user } = await this.auth.email.verify(email, code)
126
- this.user = user
127
- if (passkeys) this.set({ step: 'offer' })
128
- else this.done(user)
219
+ await this.auth.password.signUp(typed, password)
220
+ this.set({ step: 'code' })
129
221
  })
130
222
  })
131
- on('passkey', () => this.run(async () => this.done((await this.auth.passkey.signIn()).user)))
223
+ on('code', () => {
224
+ const code = value('code')
225
+ return this.run(async () => this.signedIn(await this.auth.email.verify(email, code)))
226
+ })
227
+ on('two-factor', () => {
228
+ const code = value('code')
229
+ return this.run(async () => this.signedIn(await this.auth.twoFactor.verify(recovery ? { recoveryCode: code } : { code }), { offer: false }))
230
+ })
231
+ on('passkey', () => this.run(async () => this.signedIn(await this.auth.passkey.signIn(), { offer: false })))
232
+ on('mode-code', () => this.set({ mode: 'code', error: '' }))
233
+ on('mode-password', () => this.set({ mode: 'password', error: '' }))
234
+ on('mode-sign-up', () => this.set({ mode: 'sign-up', error: '' }))
235
+ on('use-recovery', () => this.set({ recovery: true, error: '' }))
236
+ on('use-app', () => this.set({ recovery: false, error: '' }))
132
237
  on('back', () => this.set({ step: 'email', error: '' }))
133
238
  on('resend', () => this.run(() => this.auth.email.start(email)))
134
239
  on('add', () =>
135
240
  this.run(async () => {
136
241
  await this.auth.passkey.register()
137
- this.done(this.user)
242
+ await this.done(this.user)
138
243
  }),
139
244
  )
140
245
  on('skip', () => this.done(this.user))
141
- this.shadowRoot.querySelector('input')?.focus({ preventScroll: true })
246
+ const first = this.shadowRoot.querySelector(step === 'email' && email ? '#password, input' : 'input')
247
+ first?.focus({ preventScroll: true })
142
248
  }
143
249
  }
144
250
 
145
- function escape(text) {
146
- return String(text).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
147
- }
148
-
149
251
  if (typeof customElements !== 'undefined' && !customElements.get('slauth-sign-in')) {
150
252
  customElements.define('slauth-sign-in', SlauthSignIn)
151
253
  }
@@ -1,13 +1,14 @@
1
1
  /**
2
- * Prints sign-in codes to the console instead of emailing them. The default while
3
- * you build, so there's nothing to set up to try slauth.
2
+ * Prints sign-in codes (and links) to the console instead of emailing them. The default
3
+ * while you build, so there's nothing to set up to try slauth.
4
4
  *
5
5
  * @returns {import('../index.js').EmailSender}
6
6
  */
7
7
  export function consoleEmail() {
8
8
  return {
9
- async send({ to, code, appName }) {
10
- console.log(`\n 🦥 slauth: ${appName} sign-in code for ${to} is ${code}\n`)
9
+ async send({ to, code, link, appName, kind = 'sign-in', subject }) {
10
+ if (kind === 'sign-in') console.log(`\n 🦥 slauth: ${appName} sign-in code for ${to} is ${code}${link ? `\n or open ${link}` : ''}\n`)
11
+ else console.log(`\n 🦥 slauth: email to ${to}: ${subject}${code ? `\n code: ${code}` : ''}${link ? `\n link: ${link}` : ''}\n`)
11
12
  },
12
13
  }
13
14
  }
@@ -1,16 +1,56 @@
1
- /** The sign-in email every sender uses, unless you pass your own `message`. */
1
+ import { escapeHtml } from '../util.js'
2
+
3
+ /**
4
+ * The emails slauth sends, unless you pass your own `emails` function in the config.
5
+ *
6
+ * sign-in a 6-digit code (and a link, with magicLinks on)
7
+ * email-change a code to confirm a new address
8
+ * invite an invitation to join an organization
9
+ */
10
+ export function defaultMessage(kind, { code, link, appName, minutes, orgName, inviter }) {
11
+ if (kind === 'email-change') {
12
+ return box({
13
+ subject: `${code} is your code to confirm this email for ${appName}`,
14
+ lines: [`Enter this code in ${escapeHtml(appName)} to use this address from now on:`],
15
+ textLines: [`Enter this code in ${appName} to use this address from now on:`],
16
+ code,
17
+ footer: `It works for ${minutes} minutes. If you didn't ask for it, you can ignore this email.`,
18
+ })
19
+ }
20
+ if (kind === 'invite') {
21
+ return box({
22
+ subject: `${inviter ? `${inviter} invited you` : "You're invited"} to join ${orgName} on ${appName}`,
23
+ lines: [`${inviter ? `${escapeHtml(inviter)} invited you` : "You're invited"} to join <strong>${escapeHtml(orgName)}</strong> on ${escapeHtml(appName)}.`],
24
+ textLines: [`${inviter ? `${inviter} invited you` : "You're invited"} to join ${orgName} on ${appName}.`],
25
+ link,
26
+ linkLabel: 'Accept the invitation',
27
+ footer: `Sign in with this email address to accept. The invitation lasts 7 days.`,
28
+ })
29
+ }
30
+ return box({
31
+ subject: `${code} is your ${appName} sign-in code`,
32
+ lines: [`Your <strong>${escapeHtml(appName)}</strong> sign-in code:`],
33
+ textLines: [`Your ${appName} sign-in code is ${code}`],
34
+ code,
35
+ link,
36
+ linkLabel: `Sign in to ${appName}`,
37
+ footer: `It works for ${minutes} minutes. If you didn't ask for it, you can ignore this email.`,
38
+ })
39
+ }
40
+
41
+ /** The sign-in email, as slauth 1.0 exported it. */
2
42
  export function codeMessage({ code, appName, minutes }) {
3
- const subject = `${code} is your ${appName} sign-in code`
4
- const text = `Your ${appName} sign-in code is ${code}\n\nIt works for ${minutes} minutes. If you didn't ask for it, you can ignore this email.`
43
+ return defaultMessage('sign-in', { code, appName, minutes })
44
+ }
45
+
46
+ function box({ subject, lines, textLines = lines, code, link, linkLabel, footer }) {
47
+ const text = [...textLines, code ? `\n${code}` : '', link ? `\n${linkLabel}: ${link}` : '', `\n${footer}`].filter(Boolean).join('\n')
5
48
  const html = `<!doctype html><html><body style="margin:0;padding:32px 16px;background:#fdf6ec;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#2b2118">
6
49
  <div style="max-width:420px;margin:0 auto;background:#fff;border:2px solid #2b2118;border-radius:16px;padding:28px">
7
- <p style="margin:0 0 8px;font-size:15px">Your <strong>${escapeHtml(appName)}</strong> sign-in code:</p>
8
- <p style="margin:0 0 16px;font-size:34px;font-weight:800;letter-spacing:6px">${code}</p>
9
- <p style="margin:0;font-size:13px;color:#6b5d50">It works for ${minutes} minutes. If you didn't ask for it, you can ignore this email.</p>
50
+ ${lines.map((l) => `<p style="margin:0 0 8px;font-size:15px">${l}</p>`).join('\n')}
51
+ ${code ? `<p style="margin:0 0 16px;font-size:34px;font-weight:800;letter-spacing:6px">${escapeHtml(code)}</p>` : ''}
52
+ ${link ? `<p style="margin:8px 0 16px"><a href="${escapeHtml(link)}" style="display:inline-block;background:#f59e0b;color:#2b2118;border:2px solid #2b2118;border-radius:10px;padding:10px 16px;font-weight:700;text-decoration:none">${escapeHtml(linkLabel)}</a></p>` : ''}
53
+ <p style="margin:0;font-size:13px;color:#6b5d50">${escapeHtml(footer)}</p>
10
54
  </div></body></html>`
11
55
  return { subject, text, html }
12
56
  }
13
-
14
- function escapeHtml(text) {
15
- return String(text).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
16
- }
package/src/email-code.js CHANGED
@@ -1,53 +1,67 @@
1
- import { codeMessage } from './email/message.js'
1
+ import { defaultMessage } from './email/message.js'
2
2
  import { hmac, safeEqual } from './util.js'
3
3
 
4
- const CODE_MINUTES = 10
4
+ export const CODE_MINUTES = 10
5
5
  const MAX_TRIES = 5
6
6
  const RESEND_SECONDS = 60
7
7
 
8
8
  /**
9
- * Sign in with a 6-digit code sent by email.
9
+ * 6-digit codes sent by email: for signing in, and for confirming a new address.
10
10
  *
11
- * code:<email> { hash, expiresAt, tries, sentAt } (lives 10 minutes)
11
+ * code:<email> { hash, expiresAt, tries, sentAt, ... } (lives 10 minutes)
12
+ * code-change:<userId> the same, for a new email address
12
13
  *
13
14
  * Only an HMAC of the code is stored. Each code allows 5 tries, and a new code can be
14
15
  * asked for once a minute per address.
15
16
  *
16
- * @param {{ store: import('./index.js').Store, secret: string, sender: import('./index.js').EmailSender, appName: string }} options
17
+ * @param {{ store: import('./index.js').Store, secret: string, sender: import('./index.js').EmailSender, appName: string, message?: import('./index.js').SlauthConfig['emails'] }} options
17
18
  */
18
- export function createEmailCodes({ store, secret, sender, appName }) {
19
+ export function createEmailCodes({ store, secret, sender, appName, message }) {
19
20
  const hashOf = (email, code) => hmac(secret, `code:${email}:${code}`)
20
21
 
21
22
  return {
22
- /** @returns {Promise<{ ok: true } | { ok: false, code: 'slow-down', retryAfter: number }>} */
23
- async start(email) {
24
- const existing = await store.get(`code:${email}`)
23
+ /**
24
+ * @param {string} email
25
+ * @param {{ key?: string, kind?: 'sign-in' | 'email-change', link?: string, data?: object }} [options]
26
+ * @returns {Promise<{ ok: true } | { ok: false, code: 'slow-down', retryAfter: number }>}
27
+ */
28
+ async start(email, { key = `code:${email}`, kind = 'sign-in', link, data = {} } = {}) {
29
+ const existing = await store.get(key)
25
30
  const wait = existing ? Math.ceil((existing.sentAt + RESEND_SECONDS * 1000 - Date.now()) / 1000) : 0
26
31
  if (wait > 0) return { ok: false, code: 'slow-down', retryAfter: wait }
27
32
 
28
33
  const code = randomCode()
29
34
  const now = Date.now()
30
- await store.put(`code:${email}`, { hash: await hashOf(email, code), expiresAt: now + CODE_MINUTES * 60_000, tries: 0, sentAt: now }, { ttl: CODE_MINUTES * 60 })
31
- await sender.send({ to: email, code, appName, ...codeMessage({ code, appName, minutes: CODE_MINUTES }) })
35
+ await store.put(key, { ...data, email, hash: await hashOf(email, code), expiresAt: now + CODE_MINUTES * 60_000, tries: 0, sentAt: now }, { ttl: CODE_MINUTES * 60 })
36
+ await send({ kind, to: email, code, link, minutes: CODE_MINUTES })
32
37
  return { ok: true }
33
38
  },
34
39
 
35
- /** @returns {Promise<{ ok: true } | { ok: false, code: 'expired' | 'too-many-tries' | 'bad-code', triesLeft?: number }>} */
36
- async verify(email, code) {
37
- const entry = await store.get(`code:${email}`)
38
- if (!entry || entry.expiresAt <= Date.now()) return { ok: false, code: 'expired' }
40
+ /** @returns {Promise<{ ok: true, entry: object } | { ok: false, code: 'expired' | 'too-many-tries' | 'bad-code', triesLeft?: number }>} */
41
+ async verify(email, code, { key = `code:${email}` } = {}) {
42
+ const entry = await store.get(key)
43
+ if (!entry || entry.expiresAt <= Date.now() || (entry.email && entry.email !== email)) return { ok: false, code: 'expired' }
39
44
  if (entry.tries >= MAX_TRIES) return { ok: false, code: 'too-many-tries' }
40
45
 
41
46
  const guess = String(code ?? '').replace(/\D/g, '')
42
47
  if (guess.length === 6 && safeEqual(await hashOf(email, guess), entry.hash)) {
43
- await store.delete(`code:${email}`)
44
- return { ok: true }
48
+ await store.delete(key)
49
+ return { ok: true, entry }
45
50
  }
46
51
  const tries = entry.tries + 1
47
52
  const ttl = Math.max(1, Math.ceil((entry.expiresAt - Date.now()) / 1000))
48
- await store.put(`code:${email}`, { ...entry, tries }, { ttl })
53
+ await store.put(key, { ...entry, tries }, { ttl })
49
54
  return tries >= MAX_TRIES ? { ok: false, code: 'too-many-tries' } : { ok: false, code: 'bad-code', triesLeft: MAX_TRIES - tries }
50
55
  },
56
+
57
+ send,
58
+ }
59
+
60
+ /** Builds an email (yours, if you passed `emails`) and hands it to the sender. */
61
+ async function send({ kind, to, code, link, ...details }) {
62
+ const data = { code, link, appName, ...details }
63
+ const built = (message && (await message(kind, data))) || defaultMessage(kind, data)
64
+ await sender.send({ to, code, link, appName, kind, ...built })
51
65
  }
52
66
  }
53
67