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/CHANGELOG.md ADDED
@@ -0,0 +1,72 @@
1
+ # Changelog
2
+
3
+ ## 2.0.0
4
+
5
+ slauth keeps its five-minute start and now goes as deep as a project needs. Everything
6
+ new is opt-in and still dependency-free.
7
+
8
+ ### New
9
+
10
+ - **Sign in with providers** (`providers`): `github()`, `google()`, `discord()`, `gitlab()`,
11
+ `microsoft()`, any OpenID Connect issuer with `oidc()`, or your own with `oauth2()`, from
12
+ `slauthjs/providers`. PKCE, browser-bound state, account linking and unlinking, and
13
+ sign-in by email only when the provider says it's verified.
14
+ - **Magic links** (`magicLinks`): a one-tap link in the code email, behind a click so mail
15
+ scanners don't use it up.
16
+ - **Passwords** (`passwords`): PBKDF2-SHA256, email-verified sign-up, per-email lockout.
17
+ - **Two-factor** (`twoFactor`): TOTP with one-time recovery codes.
18
+ - **Tokens** (`tokens`): ES256 JWTs at `GET /auth/token`, public keys at `/auth/jwks.json`,
19
+ `auth.createToken` / `auth.verifyToken`; `auth.user()` accepts them as bearer tokens.
20
+ - **Organizations** (`organizations`): owners, admins and custom roles, email invitations,
21
+ `auth.orgs.require()`.
22
+ - **Users**: `name`, `image`, `roles`, `metadata` (public) and `privateMetadata`;
23
+ `requireUser(request, { role })`; email change with a code; self-service deletion.
24
+ - **Admin API**: `auth.users.get/byEmail/list/create/update/delete/ban/unban`,
25
+ `auth.sessions.list/revoke/revokeAll/create` (with `impersonatorId`), `auth.accounts.list`,
26
+ `auth.session(request)`.
27
+ - **Sessions**: list your devices, sign one or all others out. Bearer session tokens for
28
+ apps without cookies (`X-Slauth-Transport: bearer`, `createClient({ token })`).
29
+ - **Events** (`on`): `signUp`, `signIn`, `signOut`, `userUpdated`, `userDeleted`.
30
+ - **Custom emails** (`emails`), and senders now get `kind` and `link`.
31
+ - **Per-IP rate limits** (`rateLimit`) on code, password and two-factor routes.
32
+ - `allow(email, context)` now gets the sign-in method, provider and profile.
33
+ - `GET /auth/config` tells sign-in screens what's turned on.
34
+ - `<slauth-sign-in>` offers providers, passwords, sign-up (`mode="sign-up"`) and the
35
+ two-factor step, and shows provider errors.
36
+ - `<slauth-account>` (`slauthjs/account`): profile, email, passkeys, password, providers,
37
+ two-factor, devices, invitations, sign out and delete.
38
+ - `Store.list()` (optional) on every built-in store, for `auth.users.list()`.
39
+ - `baseUrl`, `signInPage`, `freshMinutes`, `allowDeletion` options.
40
+
41
+ ### Changed
42
+
43
+ - **Adding a passkey now needs a recent sign-in** (15 minutes by default, `freshMinutes`),
44
+ like setting a password, changing email, turning on two-factor, linking a provider and
45
+ deleting the account. The sign-in box's passkey offer comes straight after sign-in, so
46
+ it's unaffected; a settings-page "Add passkey" button may now get `reauth-required`.
47
+ Set `freshMinutes: false` for the 1.x behaviour.
48
+ - Users carry more fields. Users stored by 1.x are read with defaults; nothing to migrate.
49
+ - `<slauth-sign-in>` without a bundler needs `ui.js` next to `element.js` and `client.js`.
50
+
51
+ ### Upgrading from 1.x
52
+
53
+ No data migration: users and sessions saved by 1.x keep working (old sessions join the
54
+ new per-user index the first time they're used). Check these:
55
+
56
+ 1. **A settings-page "Add passkey" button** may now answer `reauth-required` for people
57
+ who signed in over 15 minutes ago. Ask them to sign in again, or set `freshMinutes`.
58
+ 2. **Serving the element without a bundler?** Copy `ui.js` next to `element.js` and
59
+ `client.js`, or the element won't load.
60
+ 3. **Per-IP rate limits are on** where the host sets `CF-Connecting-IP`, `Fly-Client-IP`
61
+ or `X-Real-IP`: 10 code requests per IP per 10 minutes. Many people behind one office
62
+ IP could hit it; raise it with `rateLimit.limits` or set `rateLimit: false`.
63
+ 4. **A few more store writes**: each sign-in also updates the person's session list, and
64
+ rate limiting keeps counters. Fine for D1 and SQLite; on Workers KV's free tier
65
+ (1,000 writes a day), consider `rateLimit: false`.
66
+ 5. `auth.user()` returns more fields (`name`, `image`, `roles`, `metadata`). Code that
67
+ compared the whole object, or stored it, sees the new keys.
68
+
69
+ ## 1.0.0
70
+
71
+ Email codes, passkeys, sessions, stores (D1, SQLite, KV, memory), Resend and Cloudflare
72
+ Email senders, the client, `<slauth-sign-in>`, Cloudflare Access support and the PIN app lock.
package/README.md CHANGED
@@ -1,9 +1,11 @@
1
1
  # slauth 🦥
2
2
 
3
- **Auth for side projects.** Email codes and passkeys in five minutes, in your own database,
4
- on whatever you deploy to. Zero dependencies, about 29 kB.
3
+ **Open-source auth you own.** Email codes and passkeys in five minutes, in your own database,
4
+ on whatever you deploy to. Then, when the side project grows up: "Sign in with GitHub",
5
+ passwords, two-factor, organizations, an admin API and JWTs, one config key at a time.
6
+ Zero dependencies.
5
7
 
6
- Sturdier than a password in an env var, and a lot less fuss than a hosted auth company.
8
+ Like Auth.js meets a free Clerk, with the simplicity turned all the way up.
7
9
 
8
10
  ```js
9
11
  import { slauth } from 'slauthjs'
@@ -17,7 +19,7 @@ const auth = slauth({
17
19
  })
18
20
 
19
21
  if (url.pathname.startsWith('/auth/')) return auth.handle(request)
20
- const user = await auth.user(request) // { id, email, createdAt } or null
22
+ const user = await auth.user(request) // { id, email, name, image, roles, metadata, createdAt } or null
21
23
  ```
22
24
 
23
25
  ```html
@@ -25,13 +27,45 @@ const user = await auth.user(request) // { id, email, createdAt } or null
25
27
  <slauth-sign-in app-name="My App" redirect="/"></slauth-sign-in>
26
28
  ```
27
29
 
30
+ ## Out of the box
31
+
28
32
  - **Email codes**: 6 digits, 10 minutes, 5 tries, stored as an HMAC.
29
33
  - **Passkeys**: WebAuthn (ES256 + RS256) checked with Web Crypto, no libraries.
30
34
  - **Your database**: D1, SQLite (bun:sqlite, node:sqlite, better-sqlite3), Workers KV, memory, or any `get`/`put`/`delete`.
31
35
  - **Runs anywhere** with `Request`/`Response`: Cloudflare Workers, Bun, Deno, Node, Next.js, Astro, Hono.
32
- - **Extras**: an allow list (`['me@example.com', '@family.com']`), an optional PIN app lock, and Cloudflare Access support.
36
+ - **Sessions** that people can see and revoke, per-IP rate limits, CSRF protection.
37
+
38
+ ## When you need more
39
+
40
+ Every one of these is off until you turn it on:
41
+
42
+ ```js
43
+ import { github, google, oidc } from 'slauthjs/providers'
44
+
45
+ slauth({
46
+ ...,
47
+ providers: [github({ clientId, clientSecret }), google({ clientId, clientSecret }), oidc({ id: 'okta', name: 'Okta', issuer, clientId })],
48
+ magicLinks: true, // a one-tap link in the code email
49
+ passwords: true, // optional, and only after proving the email
50
+ twoFactor: true, // authenticator apps + recovery codes
51
+ organizations: true, // teams, roles, email invitations
52
+ tokens: true, // ES256 JWTs + /auth/jwks.json for your other services
53
+ on: { signUp: ({ user }) => createProfile(user.id) },
54
+ })
55
+ ```
56
+
57
+ | | |
58
+ |---|---|
59
+ | **Providers** | GitHub, Google, Discord, GitLab, Microsoft, any OpenID Connect issuer, or a custom `oauth2()`. PKCE, account linking, verified emails only. |
60
+ | **Passwords** | PBKDF2 via Web Crypto. "Forgot password" is just an email code. |
61
+ | **Two-factor** | TOTP (RFC 6238) with one-time recovery codes. Passkeys skip it. |
62
+ | **Users** | Names, pictures, roles, public and private metadata, email change, self-service deletion. `requireUser(request, { role: 'admin' })`. |
63
+ | **Admin API** | `auth.users.list/create/update/delete/ban`, `auth.sessions.revokeAll`, impersonation. |
64
+ | **Organizations** | Owners, admins and your own roles; invitations by email; `auth.orgs.require(request, orgId)`. |
65
+ | **Apps without cookies** | Bearer session tokens for mobile apps and CLIs. |
66
+ | **UI** | `<slauth-sign-in>` offers whatever's on; `<slauth-account>` is a whole account page in one tag. |
33
67
 
34
- 📖 **Docs: [slauth.dev/docs](https://slauth.dev/docs/)**: [quickstart](https://slauth.dev/docs/quickstart/), [frameworks](https://slauth.dev/docs/frameworks/cloudflare-workers/), [security](https://slauth.dev/docs/concepts/security/).
68
+ 📖 **Docs: [slauth.dev/docs](https://slauth.dev/docs/)**: [quickstart](https://slauth.dev/docs/quickstart/), [providers](https://slauth.dev/docs/sign-in/providers/), [frameworks](https://slauth.dev/docs/frameworks/cloudflare-workers/), [security](https://slauth.dev/docs/concepts/security/). What changed: [CHANGELOG.md](CHANGELOG.md).
35
69
 
36
70
  ## Examples
37
71
 
@@ -41,7 +75,7 @@ const user = await auth.user(request) // { id, email, createdAt } or null
41
75
 
42
76
  ## Not for
43
77
 
44
- Enterprise SSO/SAML, orgs and roles, or dozens of OAuth providers. For those, see
78
+ SAML, SCIM, per-organization SSO, or a hosted dashboard you didn't build. For those, see
45
79
  [the comparison](https://slauth.dev/compare).
46
80
 
47
81
  ## Repository
@@ -51,6 +85,9 @@ Enterprise SSO/SAML, orgs and roles, or dozens of OAuth providers. For those, se
51
85
  | `src/` | the library (plain ESM + `.d.ts`) |
52
86
  | `test/` | test helpers, a simulated passkey authenticator, type checks, a browser test |
53
87
  | `examples/` | runnable apps |
54
- | `site/` | slauth.dev: Astro marketing pages + Starlight docs |
55
88
 
56
- See [CONTRIBUTING.md](CONTRIBUTING.md). MIT licensed.
89
+ The site and docs (slauth.dev) live in a separate repository. Spotted a docs mistake?
90
+ [Open an issue](https://github.com/silicondaydream/slauth/issues) here.
91
+
92
+ See [CONTRIBUTING.md](CONTRIBUTING.md) and [CODE_POLICY.md](CODE_POLICY.md). Security
93
+ reports: [SECURITY.md](SECURITY.md). MIT licensed.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "slauthjs",
3
- "version": "1.0.0",
4
- "description": "Auth for side projects: email codes and passkeys, your own database, zero dependencies. Runs on Workers, Bun, Deno and Node.",
3
+ "version": "2.0.0",
4
+ "description": "Open-source auth you own: email codes, passkeys, \"Sign in with GitHub/Google\", passwords, two-factor, sessions, organizations and JWTs. Your database, zero dependencies. Runs on Workers, Bun, Deno and Node.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "./src/index.js",
@@ -15,10 +15,18 @@
15
15
  "types": "./src/client.d.ts",
16
16
  "default": "./src/client.js"
17
17
  },
18
+ "./providers": {
19
+ "types": "./src/providers.d.ts",
20
+ "default": "./src/providers.js"
21
+ },
18
22
  "./element": {
19
23
  "types": "./src/element.d.ts",
20
24
  "default": "./src/element.js"
21
25
  },
26
+ "./account": {
27
+ "types": "./src/account-element.d.ts",
28
+ "default": "./src/account-element.js"
29
+ },
22
30
  "./node": {
23
31
  "types": "./src/node.d.ts",
24
32
  "default": "./src/node.js"
@@ -54,10 +62,12 @@
54
62
  },
55
63
  "files": [
56
64
  "src",
65
+ "CHANGELOG.md",
57
66
  "!src/**/*.test.js"
58
67
  ],
59
68
  "sideEffects": [
60
- "./src/element.js"
69
+ "./src/element.js",
70
+ "./src/account-element.js"
61
71
  ],
62
72
  "engines": {
63
73
  "node": ">=20"
@@ -65,7 +75,8 @@
65
75
  "scripts": {
66
76
  "test": "vitest run",
67
77
  "typecheck": "tsc -p tsconfig.json",
68
- "prepublishOnly": "vitest run && tsc -p tsconfig.json"
78
+ "prepublishOnly": "vitest run && tsc -p tsconfig.json",
79
+ "smoke": "node test/smoke.mjs"
69
80
  },
70
81
  "devDependencies": {
71
82
  "@types/node": "^22.20.5",
@@ -75,18 +86,31 @@
75
86
  "keywords": [
76
87
  "auth",
77
88
  "authentication",
89
+ "oauth",
90
+ "oidc",
91
+ "sign-in-with-github",
78
92
  "passkeys",
79
93
  "webauthn",
80
- "magic-code",
94
+ "two-factor",
95
+ "totp",
96
+ "magic-link",
97
+ "sessions",
98
+ "organizations",
99
+ "jwt",
100
+ "nextauth-alternative",
101
+ "clerk-alternative",
81
102
  "cloudflare-workers",
82
103
  "d1",
83
104
  "bun",
84
- "deno",
85
- "side-project"
105
+ "deno"
86
106
  ],
87
107
  "homepage": "https://slauth.dev",
88
108
  "repository": {
89
109
  "type": "git",
90
110
  "url": "git+https://github.com/silicondaydream/slauth.git"
111
+ },
112
+ "packageManager": "pnpm@10.28.0",
113
+ "publishConfig": {
114
+ "access": "public"
91
115
  }
92
116
  }
@@ -0,0 +1,8 @@
1
+ /** Importing this registers <slauth-account>. */
2
+ export {}
3
+
4
+ declare global {
5
+ interface HTMLElementTagNameMap {
6
+ 'slauth-account': HTMLElement
7
+ }
8
+ }
@@ -0,0 +1,244 @@
1
+ import { createClient } from './client.js'
2
+ import { MESSAGES, STYLE, escape } from './ui.js'
3
+
4
+ /**
5
+ * <slauth-account>: an account page in one tag. Name and email, passkeys, password,
6
+ * linked providers, two-factor, signed-in devices, organization invitations, and
7
+ * signing out or deleting the account. It shows only what the server has turned on,
8
+ * and is themed with the same CSS variables as <slauth-sign-in>.
9
+ *
10
+ * Attributes: base-path (default /auth), signed-out (where to go after signing out or
11
+ * deleting the account; default "/").
12
+ *
13
+ * import 'slauthjs/account'
14
+ * <slauth-account></slauth-account>
15
+ */
16
+ class SlauthAccount extends HTMLElement {
17
+ connectedCallback() {
18
+ this.auth = createClient({ basePath: this.getAttribute('base-path') ?? '/auth' })
19
+ this.data = null
20
+ this.ui = { error: '', notice: '', busy: false, editing: null, setup: null, recoveryCodes: null, newEmail: '' }
21
+ this.attachShadow({ mode: 'open' })
22
+ this.render()
23
+ this.load()
24
+ }
25
+
26
+ async load() {
27
+ try {
28
+ const [session, config] = await Promise.all([this.auth.session(), this.auth.config()])
29
+ if (!session.user) {
30
+ this.data = { signedOut: true }
31
+ return this.render()
32
+ }
33
+ const [passkeys, accounts, sessions, invites] = await Promise.all([
34
+ config.passkeys ? this.auth.passkey.list() : { passkeys: [] },
35
+ this.auth.accounts.list(),
36
+ this.auth.sessions.list(),
37
+ config.organizations ? this.auth.orgs.invites() : { invites: [] },
38
+ ])
39
+ const orgs = config.organizations ? (await this.auth.orgs.list()).orgs : []
40
+ this.data = { user: session.user, config, passkeys: passkeys.passkeys, ...accounts, sessions: sessions.sessions, invites: invites.invites, orgs }
41
+ } catch (error) {
42
+ this.ui.error = error.message
43
+ }
44
+ this.render()
45
+ }
46
+
47
+ async run(action, notice = '') {
48
+ this.ui = { ...this.ui, busy: true, error: '', notice: '' }
49
+ this.render()
50
+ try {
51
+ await action()
52
+ this.ui.notice = notice
53
+ await this.load()
54
+ } catch (error) {
55
+ this.ui.error = MESSAGES[error.code] ?? error.message ?? 'Something went wrong.'
56
+ } finally {
57
+ this.ui.busy = false
58
+ if (this.isConnected) this.render()
59
+ }
60
+ }
61
+
62
+ leave() {
63
+ window.location.assign(this.getAttribute('signed-out') ?? '/')
64
+ }
65
+
66
+ render() {
67
+ const d = this.data
68
+ const { error, notice, busy, editing, setup, recoveryCodes } = this.ui
69
+ const off = busy ? 'disabled' : ''
70
+ const date = (ms) => new Date(ms).toLocaleDateString(undefined, { dateStyle: 'medium' })
71
+ let body
72
+ if (!d) body = '<p>Loading…</p>'
73
+ else if (d.signedOut) body = '<h2>Your account</h2><p>Please sign in first.</p>'
74
+ else {
75
+ const { user, config } = d
76
+ const providerRows = config.providers
77
+ .map((p) => {
78
+ const linked = d.accounts.find((a) => a.provider === p.id)
79
+ return `<li><span>${escape(p.name)}${linked ? ` <span class="muted">${escape(linked.email ?? linked.username ?? '')}</span>` : ''}</span>${
80
+ linked ? `<button class="link" data-on="unlink" data-id="${escape(p.id)}" ${off}>Disconnect</button>` : `<button class="link" data-on="link" data-id="${escape(p.id)}" ${off}>Connect</button>`
81
+ }</li>`
82
+ })
83
+ .join('')
84
+ body = `
85
+ <h2>Your account</h2>
86
+ <p>${escape(user.email)}</p>
87
+
88
+ <h3>Profile</h3>
89
+ <form data-on="name">
90
+ <label for="name">Name</label>
91
+ <input id="name" autocomplete="name" value="${escape(user.name ?? '')}" />
92
+ <button class="ghost" ${off}>Save name</button>
93
+ </form>
94
+ ${
95
+ editing === 'email'
96
+ ? this.ui.newEmail
97
+ ? `<form data-on="email-code"><label for="email-code">Code sent to ${escape(this.ui.newEmail)}</label><input id="email-code" class="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" required /><button class="primary" ${off}>Confirm new email</button></form>`
98
+ : `<form data-on="email"><label for="email">New email</label><input id="email" type="email" autocomplete="email" required /><button class="primary" ${off}>Send a code</button></form>`
99
+ : `<div class="row"><button class="link" data-on="edit-email">Change email</button></div>`
100
+ }
101
+
102
+ ${
103
+ config.passkeys
104
+ ? `<h3>Passkeys</h3>
105
+ <ul class="list">${d.passkeys.map((pk) => `<li><span>${escape(pk.name)} <span class="muted">added ${date(pk.createdAt)}</span></span><button class="link" data-on="remove-passkey" data-id="${escape(pk.id)}" ${off}>Remove</button></li>`).join('') || '<li><span class="muted">None yet.</span></li>'}</ul>
106
+ ${this.auth.passkey.supported() ? `<button class="ghost dashed" data-on="add-passkey" ${off}>Add a passkey</button>` : ''}`
107
+ : ''
108
+ }
109
+
110
+ ${
111
+ config.passwords || config.providers.length
112
+ ? `<h3>Ways to sign in</h3>
113
+ <ul class="list">
114
+ ${config.passwords ? `<li><span>Password <span class="muted">${d.password ? 'set' : 'not set'}</span></span><button class="link" data-on="edit-password">${d.password ? 'Change' : 'Set'}</button></li>` : ''}
115
+ ${providerRows}
116
+ </ul>
117
+ ${
118
+ editing === 'password'
119
+ ? `<form data-on="password">${d.password ? '<label for="current">Current password</label><input id="current" type="password" autocomplete="current-password" />' : ''}<label for="password">New password</label><input id="password" type="password" autocomplete="new-password" minlength="8" required /><button class="primary" ${off}>Save password</button></form>`
120
+ : ''
121
+ }`
122
+ : ''
123
+ }
124
+
125
+ ${
126
+ config.twoFactor
127
+ ? `<h3>Two-factor sign-in</h3>
128
+ ${
129
+ recoveryCodes
130
+ ? `<p>Save these recovery codes somewhere safe. Each one works once if you lose your phone.</p><div class="codes">${recoveryCodes.map((c) => `<span>${escape(c)}</span>`).join('')}</div><button class="ghost" data-on="codes-done">I've saved them</button>`
131
+ : setup
132
+ ? `<p>Add this to your authenticator app (<a href="${escape(setup.uri)}">open in app</a>), or type the key:</p><p class="codes"><span>${escape(setup.secret.replace(/(.{4})/g, '$1 ').trim())}</span></p>
133
+ <form data-on="enable-2fa"><label for="totp">Code from the app</label><input id="totp" class="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" required /><button class="primary" ${off}>Turn on</button></form>`
134
+ : d.twoFactor
135
+ ? `<p>On: signing in asks for a code from your authenticator app.</p>
136
+ <form data-on="disable-2fa"><label for="totp">Code from the app (or a recovery code) to turn it off</label><input id="totp" autocomplete="one-time-code" required /><button class="ghost" ${off}>Turn off</button></form>`
137
+ : `<p>Off. Turn it on to ask for a code from an authenticator app when you sign in.</p><button class="ghost" data-on="setup-2fa" ${off}>Set up</button>`
138
+ }`
139
+ : ''
140
+ }
141
+
142
+ <h3>Signed-in devices</h3>
143
+ <ul class="list">${d.sessions
144
+ .map((s) => `<li><span>${escape(device(s.userAgent))} <span class="muted">${s.current ? 'this device' : `since ${date(s.createdAt)}`}</span></span>${s.current ? '' : `<button class="link" data-on="revoke" data-id="${escape(s.id)}" ${off}>Sign out</button>`}</li>`)
145
+ .join('')}</ul>
146
+ ${d.sessions.length > 1 ? `<button class="ghost" data-on="revoke-others" ${off}>Sign out everywhere else</button>` : ''}
147
+
148
+ ${
149
+ config.organizations && (d.invites.length || d.orgs.length)
150
+ ? `<h3>Organizations</h3>
151
+ <ul class="list">
152
+ ${d.invites.map((i) => `<li><span>${escape(i.org.name)} <span class="muted">invited as ${escape(i.role)}</span></span><span><button class="link" data-on="accept" data-id="${escape(i.id)}" ${off}>Join</button> <button class="link" data-on="decline" data-id="${escape(i.id)}" ${off}>Decline</button></span></li>`).join('')}
153
+ ${d.orgs.map((o) => `<li><span>${escape(o.name)}</span><span class="muted">${escape(o.role)}</span></li>`).join('')}
154
+ </ul>`
155
+ : ''
156
+ }
157
+
158
+ <h3>Leave</h3>
159
+ <button class="ghost" data-on="sign-out" ${off}>Sign out</button>
160
+ ${editing === 'delete' ? `<p>This deletes your account and everything in it, for good.</p><button class="primary" data-on="delete" ${off}>Yes, delete my account</button>` : `<div class="row"><button class="link" data-on="edit-delete">Delete my account</button></div>`}`
161
+ }
162
+ this.shadowRoot.innerHTML = `<style>${STYLE}:host { max-width: 480px; }</style><div class="card" part="card">${body}${notice ? `<p role="status">${escape(notice)}</p>` : ''}<p class="error" role="alert">${escape(error)}</p></div>`
163
+
164
+ const on = (key, handler) => {
165
+ for (const el of this.shadowRoot.querySelectorAll(`[data-on="${key}"]`)) {
166
+ el.addEventListener(el.tagName === 'FORM' ? 'submit' : 'click', (event) => {
167
+ event.preventDefault()
168
+ handler(el.dataset.id)
169
+ })
170
+ }
171
+ }
172
+ const value = (id) => this.shadowRoot.getElementById(id)?.value ?? ''
173
+ const edit = (what) => ((this.ui = { ...this.ui, editing: this.ui.editing === what ? null : what, error: '', newEmail: '' }), this.render())
174
+
175
+ on('name', () => {
176
+ const name = value('name')
177
+ return this.run(() => this.auth.user.update({ name }), 'Saved.')
178
+ })
179
+ on('edit-email', () => edit('email'))
180
+ on('email', () => {
181
+ const email = value('email').trim()
182
+ return this.run(async () => {
183
+ await this.auth.email.change.start(email)
184
+ this.ui.newEmail = email
185
+ })
186
+ })
187
+ on('email-code', () => {
188
+ const code = value('email-code')
189
+ return this.run(async () => {
190
+ await this.auth.email.change.verify(this.ui.newEmail, code)
191
+ this.ui = { ...this.ui, editing: null, newEmail: '' }
192
+ }, 'Your email is changed.')
193
+ })
194
+ on('add-passkey', () => this.run(() => this.auth.passkey.register(), 'Passkey added.'))
195
+ on('remove-passkey', (id) => this.run(() => this.auth.passkey.remove(id)))
196
+ on('edit-password', () => edit('password'))
197
+ on('password', () => {
198
+ const current = value('current') || undefined
199
+ const password = value('password')
200
+ return this.run(async () => {
201
+ await this.auth.password.set(password, current)
202
+ this.ui.editing = null
203
+ }, 'Password saved.')
204
+ })
205
+ on('link', (id) => this.auth.oauth.link(id, { redirect: window.location.pathname }))
206
+ on('unlink', (id) => this.run(() => this.auth.accounts.unlink(id)))
207
+ on('setup-2fa', () =>
208
+ this.run(async () => {
209
+ this.ui.setup = await this.auth.twoFactor.setup()
210
+ }),
211
+ )
212
+ on('enable-2fa', () => {
213
+ const code = value('totp')
214
+ return this.run(async () => {
215
+ const { recoveryCodes: codes } = await this.auth.twoFactor.enable(code)
216
+ this.ui = { ...this.ui, setup: null, recoveryCodes: codes }
217
+ })
218
+ })
219
+ on('codes-done', () => ((this.ui.recoveryCodes = null), this.render()))
220
+ on('disable-2fa', () => {
221
+ const code = value('totp').trim()
222
+ return this.run(() => this.auth.twoFactor.disable(/^\d{6}$/.test(code) ? { code } : { recoveryCode: code }), 'Two-factor sign-in is off.')
223
+ })
224
+ on('revoke', (id) => this.run(() => this.auth.sessions.revoke(id)))
225
+ on('revoke-others', () => this.run(() => this.auth.sessions.revokeOthers(), 'Signed out everywhere else.'))
226
+ on('accept', (id) => this.run(() => this.auth.orgs.accept(id)))
227
+ on('decline', (id) => this.run(() => this.auth.orgs.decline(id)))
228
+ on('sign-out', () => this.run(async () => (await this.auth.signOut(), this.leave())))
229
+ on('edit-delete', () => edit('delete'))
230
+ on('delete', () => this.run(async () => (await this.auth.user.delete(), this.leave())))
231
+ }
232
+ }
233
+
234
+ /** "Chrome on macOS" from a user agent, roughly. */
235
+ function device(ua) {
236
+ if (!ua) return 'Unknown device'
237
+ const browser = /Edg\//.test(ua) ? 'Edge' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'A browser'
238
+ const os = /iPhone|iPad/.test(ua) ? 'iOS' : /Android/.test(ua) ? 'Android' : /Mac OS X/.test(ua) ? 'macOS' : /Windows/.test(ua) ? 'Windows' : /Linux/.test(ua) ? 'Linux' : ''
239
+ return os ? `${browser} on ${os}` : browser
240
+ }
241
+
242
+ if (typeof customElements !== 'undefined' && !customElements.get('slauth-account')) {
243
+ customElements.define('slauth-account', SlauthAccount)
244
+ }
package/src/client.d.ts CHANGED
@@ -1,26 +1,100 @@
1
- import type { Policy, SlauthUser } from './index.js'
1
+ import type { OrgRole, Policy, SlauthUser } from './index.js'
2
2
 
3
3
  export class SlauthError extends Error {
4
- /** e.g. "bad-code", "expired", "not-allowed", "slow-down", "signed-out", "offline". */
4
+ /** e.g. "bad-code", "expired", "not-allowed", "slow-down", "signed-out", "reauth-required", "offline". */
5
5
  code: string
6
6
  status: number
7
7
  [detail: string]: unknown
8
8
  }
9
9
 
10
+ /** A sign-in reply: the user, or a request for the two-factor code. */
11
+ export type SignInResult = { user: SlauthUser; twoFactor?: undefined } | { twoFactor: true; user?: undefined }
12
+
13
+ export interface ClientConfig {
14
+ appName: string
15
+ emailCodes: boolean
16
+ magicLinks: boolean
17
+ passkeys: boolean
18
+ passwords: boolean
19
+ twoFactor: boolean
20
+ organizations: boolean
21
+ providers: { id: string; name: string }[]
22
+ }
23
+
10
24
  export interface SlauthClient {
11
- session(): Promise<{ user: SlauthUser | null; lock?: { policy: Policy; required: boolean; pinSet: boolean; unlocked: boolean } }>
25
+ session(): Promise<{ user: SlauthUser | null; twoFactor?: 'pending'; impersonatorId?: string; lock?: { policy: Policy; required: boolean; pinSet: boolean; unlocked: boolean } }>
26
+ config(): Promise<ClientConfig>
12
27
  email: {
13
- start(email: string): Promise<{ ok: true }>
14
- verify(email: string, code: string): Promise<{ user: SlauthUser }>
28
+ start(email: string, options?: { redirect?: string }): Promise<{ ok: true }>
29
+ verify(email: string, code: string): Promise<SignInResult>
30
+ link(token: string): Promise<SignInResult & { redirect: string }>
31
+ change: {
32
+ start(email: string): Promise<{ ok: true }>
33
+ verify(email: string, code: string): Promise<{ user: SlauthUser }>
34
+ }
35
+ }
36
+ password: {
37
+ signIn(email: string, password: string): Promise<SignInResult>
38
+ signUp(email: string, password: string, options?: { redirect?: string }): Promise<{ ok: true; verify: 'email' }>
39
+ set(password: string, current?: string): Promise<{ ok: true }>
40
+ remove(): Promise<{ ok: true }>
41
+ }
42
+ oauth: {
43
+ /** Navigates to the provider; returns the URL. */
44
+ signIn(provider: string, options?: { redirect?: string }): string
45
+ link(provider: string, options?: { redirect?: string }): string
46
+ url(provider: string, options?: { redirect?: string }): string
47
+ }
48
+ twoFactor: {
49
+ verify(code: string | { code: string } | { recoveryCode: string }): Promise<{ user: SlauthUser; recoveryCodesLeft?: number }>
50
+ setup(): Promise<{ secret: string; uri: string }>
51
+ enable(code: string): Promise<{ ok: true; recoveryCodes: string[] }>
52
+ disable(code: string | { code: string } | { recoveryCode: string }): Promise<{ ok: true }>
53
+ recoveryCodes(code: string): Promise<{ ok: true; recoveryCodes: string[] }>
15
54
  }
16
55
  passkey: {
17
56
  supported(): boolean
18
57
  register(name?: string): Promise<{ ok: true; passkey: { id: string; name: string } }>
19
- signIn(): Promise<{ user: SlauthUser }>
58
+ signIn(): Promise<SignInResult>
20
59
  list(): Promise<{ passkeys: { id: string; name: string; createdAt: number; lastUsedAt: number | null }[] }>
60
+ rename(id: string, name: string): Promise<{ ok: true }>
21
61
  remove(id: string): Promise<{ ok: true }>
22
62
  }
23
63
  signOut(): Promise<{ ok: true }>
64
+ user: {
65
+ update(patch: { name?: string | null; image?: string | null }): Promise<{ user: SlauthUser }>
66
+ delete(): Promise<{ ok: true }>
67
+ }
68
+ sessions: {
69
+ list(): Promise<{ sessions: { id: string; createdAt: number; lastActiveAt: number; method: string | null; userAgent: string | null; ip: string | null; current: boolean }[] }>
70
+ revoke(id: string): Promise<{ ok: true }>
71
+ revokeOthers(): Promise<{ ok: true }>
72
+ }
73
+ accounts: {
74
+ list(): Promise<{ accounts: { provider: string; name: string; email: string | null; username: string | null; createdAt: number }[]; password: boolean; twoFactor: boolean }>
75
+ unlink(provider: string): Promise<{ ok: true }>
76
+ }
77
+ /** A JWT for your other services (needs `tokens` on), cached until shortly before it expires. */
78
+ getToken(): Promise<string>
79
+ orgs: {
80
+ list(): Promise<{ orgs: { id: string; name: string; role: OrgRole; metadata: Record<string, unknown>; createdAt: number }[] }>
81
+ create(name: string): Promise<{ org: { id: string; name: string; role: 'owner' } }>
82
+ get(id: string): Promise<{
83
+ org: { id: string; name: string; metadata: Record<string, unknown>; createdAt: number }
84
+ role: OrgRole
85
+ members: { userId: string; role: OrgRole; joinedAt: number; email: string; name: string | null; image: string | null }[]
86
+ invites?: { id: string; email: string; role: OrgRole; createdAt: number; expiresAt: number }[]
87
+ }>
88
+ rename(id: string, name: string): Promise<{ org: { id: string; name: string } }>
89
+ delete(id: string): Promise<{ ok: true }>
90
+ invite(id: string, email: string, role?: OrgRole): Promise<{ ok: true; invite: { id: string; email: string; role: OrgRole; expiresAt: number } }>
91
+ revokeInvite(id: string, inviteId: string): Promise<{ ok: true }>
92
+ setRole(id: string, userId: string, role: OrgRole): Promise<{ ok: true }>
93
+ removeMember(id: string, userId: string): Promise<{ ok: true }>
94
+ invites(): Promise<{ invites: { id: string; role: OrgRole; org: { id: string; name: string }; expiresAt: number }[] }>
95
+ accept(inviteId: string): Promise<{ ok: true; org: { id: string; name: string; role: OrgRole } }>
96
+ decline(inviteId: string): Promise<{ ok: true }>
97
+ }
24
98
  lock: {
25
99
  unlock(pin: string): Promise<{ ok: true }>
26
100
  setup(pin: string, policy?: Policy): Promise<{ ok: true }>
@@ -31,4 +105,9 @@ export interface SlauthClient {
31
105
  }
32
106
  }
33
107
 
34
- export function createClient(options?: { basePath?: string; fetch?: typeof fetch }): SlauthClient
108
+ export function createClient(options?: {
109
+ basePath?: string
110
+ fetch?: typeof fetch
111
+ /** For apps without cookies: where to keep the session token. */
112
+ token?: { get(): string | null | undefined; set(token: string | null): void | Promise<void> }
113
+ }): SlauthClient