From 6fa1604c39ef4db636064f0cd804bc041f3aa7cf Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:04:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20bulk=20import=20defaults=20to=20`?= =?UTF-8?q?auto`=20=E2=80=94=20invite=20per=20row,=20temporary=20only=20fo?= =?UTF-8?q?r=20undeliverable=20rows=20(#3236)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The identity bulk-import endpoint (`POST /api/v1/auth/admin/import-users`) gains a fourth `passwordPolicy`, `auto`, and it is now the default (was `none`). `auto` decides PER ROW instead of forcing one policy on the whole batch: - a row with a deliverable channel (real email + wired email service, or phone + wired SMS-invite) is INVITED — no shared secret leaves the server; - a row with no deliverable channel (placeholder email, phone-only without SMS, or an email row when no email service is wired) falls back to a temporary password, returned once with `must_change_password` stamped. This shrinks the temporary-password blast radius from "the whole batch" to "only the rows that genuinely can't be reached", and — unlike `invite` — `auto` never rejects the request for missing infrastructure (undeliverable rows just degrade). The per-row outcome is surfaced on `rows[].delivery` (email/sms/temporary) with a batch breakdown on `summary.delivery` and in the run audit. The write path is refactored to a single per-row plan (resolved in pre-validation, keyed by identity so it survives the coerced-row copy handed to createData) and one unified post-write pass driven by the invite/temporary maps rather than a global policy branch — so the interleaved `auto` batch and the three fixed policies share one code path. `invite`/`temporary`/`none` behave exactly as before. Behavior change: callers that OMIT `passwordPolicy` now get `auto` (proactive invitations / temporary fallback) instead of `none` (identity only). Pass `passwordPolicy: 'none'` explicitly for the old identity-only behavior. Every explicit-policy call is unaffected; the response is a strict superset. Co-Authored-By: Claude Opus 4.8 --- .changeset/import-users-auto-policy.md | 20 ++ content/docs/permissions/authentication.mdx | 40 ++- .../src/admin-import-users.test.ts | 141 +++++++++- .../plugin-auth/src/admin-import-users.ts | 241 ++++++++++++------ 4 files changed, 354 insertions(+), 88 deletions(-) create mode 100644 .changeset/import-users-auto-policy.md diff --git a/.changeset/import-users-auto-policy.md b/.changeset/import-users-auto-policy.md new file mode 100644 index 0000000000..253badb864 --- /dev/null +++ b/.changeset/import-users-auto-policy.md @@ -0,0 +1,20 @@ +--- +"@objectstack/plugin-auth": minor +--- + +**Bulk user import defaults to `auto` — prefer invite per row, temporary only for undeliverable rows (#3236).** The identity import endpoint (`POST /api/v1/auth/admin/import-users`) gains a fourth `passwordPolicy`, **`auto`**, and it is now the **default** (was `none`). + +`auto` decides **per row** instead of forcing one policy on the whole batch: + +- a row with a deliverable channel — a **real email + a wired email service**, or a **phone + a wired SMS-invite path** — is **invited** (a set-your-password email, or an invitation SMS for phone-only rows), so no shared secret ever leaves the server; +- a row with **no** deliverable channel (placeholder email, phone-only without SMS, or an email row when no email service is wired) falls back to a **temporary password**, returned once in the response with `must_change_password` stamped. + +This shrinks the temporary-password blast radius from "the whole batch" to "only the rows that genuinely can't be reached", and — unlike `invite` — `auto` **never rejects the request for missing infrastructure**: with nothing wired, every row simply degrades to temporary. The per-row outcome is surfaced on `rows[].delivery` (`email` / `sms` / `temporary`) with a batch breakdown on `summary.delivery` (also recorded in the run audit). + +The three existing policies are unchanged and still selectable explicitly: + +- `invite` — force the invite path for every row; unreachable rows are **failed** per-row (never downgraded). Pick this when a temporary-password fallback is unacceptable. +- `temporary` — force a generated temporary password for every row. +- `none` — identity only, no password and no invitation. + +**Behavior change to note:** callers that **omit** `passwordPolicy` previously got `none` (no credential, no outbound message); they now get `auto`, which proactively sends invitations to deliverable rows (and returns temporary passwords for the rest). Callers that want the old identity-only behavior must pass `passwordPolicy: 'none'` explicitly. Every call that already passes an explicit policy is unaffected, and the response is a strict superset (adds the `delivery` fields). diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index ca6757e9cf..4c92f9e88a 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -698,17 +698,28 @@ SSO-onboarded users who never had a local password. generic data import (`rows[]` / `csv` / `xlsxBase64`, `dryRun`), but writes every row through better-auth so the accounts are login-capable: -- `passwordPolicy: 'none'` (**default**) — identity only: accounts are created - without a credential record. Users first sign in through a channel (phone - OTP, magic link, or a password-reset link) and the Console detects the - missing password (`hasLocalPassword()`) and offers set-initial-password. -- `passwordPolicy: 'invite'` — `none` plus an invitation per created account: - rows need a reachable identity — a real email (set-your-password email; - requires an email service) or, for phone-only rows, a wired SMS service - (invitation SMS + phone-OTP first sign-in). -- `passwordPolicy: 'temporary'` — the no-infrastructure fallback (no email, - no SMS): per-row generated temporary passwords, returned once in the - response, with `mustChangePassword` enforced. +- `passwordPolicy: 'auto'` (**default**) — decides **per row**: a row with a + deliverable channel (a real email + a wired email service, or a phone + a + wired SMS service) is **invited** (no shared secret leaves the server); a row + with no deliverable channel (placeholder email, phone-only without SMS, or an + email row when no email service is wired) falls back to a **temporary** + password. This keeps the temporary-password blast radius to only the rows + that genuinely can't be reached, and — unlike `invite` — it never fails the + request for missing infrastructure (undeliverable rows just degrade). The + per-row outcome is reported as `rows[].delivery` (`email` / `sms` / + `temporary`), with a batch breakdown on `summary.delivery`. +- `passwordPolicy: 'invite'` — force the invite path for **every** row: a real + email gets a set-your-password email (requires an email service), a phone-only + row an invitation SMS (requires a wired SMS service + phone-OTP first sign-in). + Rows that aren't reachable are **failed per-row** (never downgraded) — pick + this when a temporary-password fallback is unacceptable. +- `passwordPolicy: 'temporary'` — force the no-infrastructure path (no email, + no SMS) for every row: per-row generated temporary passwords, returned once in + the response, with `mustChangePassword` enforced. +- `passwordPolicy: 'none'` — identity only: accounts are created without a + credential record. Users first sign in through a channel (phone OTP, magic + link, or a password-reset link) and the Console detects the missing password + (`hasLocalPassword()`) and offers set-initial-password. - `mode: 'insert' | 'upsert'` with `matchBy: 'email' | 'phone'`. Upsert updates only touch profile fields (`name`, `phone_number`, `role`) — a re-imported file can never modify an existing user's email or reset their @@ -718,8 +729,9 @@ every row through better-auth so the accounts are login-capable: In the Console, platform admins reach this from the **Users list → Import** toolbar button (shown when the admin plugin is enabled). The import wizard -uploads a CSV/Excel file, maps columns, previews with a dry-run, and — for -the `temporary` policy — reveals the generated passwords once with a +uploads a CSV/Excel file, maps columns, previews with a dry-run, and — for any +rows that resolve to a temporary password (all of them under `temporary`, only +the unreachable ones under `auto`) — reveals the generated passwords once with a download, splitting files above 500 rows into batches automatically. --- @@ -847,7 +859,7 @@ All endpoints are available under `/api/v1/auth/*`: - `POST /api/v1/auth/admin/create-user` - Create a login-capable account (optional one-time temporary password + forced change) - `POST /api/v1/auth/admin/set-user-password` - Set/reset a user's password (also provisions a credential for SSO-onboarded users) -- `POST /api/v1/auth/admin/import-users` - Bulk import users (CSV/JSON/XLSX; `none` (default) / `invite` / `temporary` password policy; ≤500 rows, dry-run supported) +- `POST /api/v1/auth/admin/import-users` - Bulk import users (CSV/JSON/XLSX; `auto` (default, per-row invite-or-temporary) / `invite` / `temporary` / `none` password policy; ≤500 rows, dry-run supported) - `POST /api/v1/auth/admin/unlock-user` - Clear a brute-force lockout early For complete API documentation, see the [Better-Auth API Reference](https://www.better-auth.com/docs). diff --git a/packages/plugins/plugin-auth/src/admin-import-users.test.ts b/packages/plugins/plugin-auth/src/admin-import-users.test.ts index b63d108995..580d5e9369 100644 --- a/packages/plugins/plugin-auth/src/admin-import-users.test.ts +++ b/packages/plugins/plugin-auth/src/admin-import-users.test.ts @@ -75,15 +75,21 @@ describe('runAdminImportUsers — request validation', () => { expect(m.createUser).not.toHaveBeenCalled(); }); - it('defaults to the none policy when passwordPolicy is omitted', async () => { - const m = makeDeps(); + it('defaults to the auto policy when passwordPolicy is omitted (#3236)', async () => { + const m = makeDeps(); // email service available, no SMS const res = await runAdminImportUsers( m.deps, makeRequest({ format: 'json', rows: [{ email: 'a@b.co' }] }), ACTOR, ); expect(res.status).toBe(200); - expect((res.body.data as any).summary.passwordPolicy).toBe('none'); + const data = res.body.data as any; + expect(data.summary.passwordPolicy).toBe('auto'); + // A deliverable email row is invited, not handed a temporary password. + expect(m.requestPasswordReset).toHaveBeenCalledTimes(1); + expect(data.rows[0].delivery).toBe('email'); + expect(data.rows[0].temporaryPassword).toBeUndefined(); + expect(data.summary.delivery).toEqual({ emailInvite: 1, smsInvite: 0, temporary: 0 }); }); it('rejects matchBy phone when the phoneNumber plugin is off', async () => { @@ -401,6 +407,135 @@ describe('runAdminImportUsers — invite policy', () => { }); }); +// #3236 — `auto` prefers the invite path per row and only falls back to a +// temporary password for rows with no deliverable channel. Unlike `invite`, it +// never rejects the request for missing infrastructure. +describe('runAdminImportUsers — auto policy (default)', () => { + it('invites deliverable rows and falls back to temporary only for the unreachable ones — in ONE batch', async () => { + // Email service up, SMS down. Row 1: real email → email invite. Row 2: + // phone-only with no SMS → temporary fallback (the ONLY row that gets a + // shared secret). That per-row split is the whole point of #3236. + const m = makeDeps({ phoneEnabled: true, emailAvailable: true, smsInviteAvailable: false }); + const res = await runAdminImportUsers( + m.deps, + makeRequest({ + passwordPolicy: 'auto', format: 'json', + rows: [ + { email: 'reachable@x.co', name: 'Mail' }, + { phone_number: '+8613800000001', name: 'PhoneOnly' }, + ], + }), + ACTOR, + ); + expect(res.status).toBe(200); + const data = res.body.data as any; + expect(data.summary.created).toBe(2); + expect(data.summary.passwordPolicy).toBe('auto'); + + // Row 1 invited by email; no temporary password. + expect(m.requestPasswordReset).toHaveBeenCalledTimes(1); + expect(m.requestPasswordReset.mock.calls[0][0].body.email).toBe('reachable@x.co'); + expect(data.rows[0].delivery).toBe('email'); + expect(data.rows[0].temporaryPassword).toBeUndefined(); + + // Row 2 fell back to temporary: a returned password + must_change stamp. + expect(m.sendInviteSms).not.toHaveBeenCalled(); + expect(typeof data.rows[1].temporaryPassword).toBe('string'); + expect(data.rows[1].delivery).toBe('temporary'); + const stamps = m.update.mock.calls.filter((c) => c[1]?.must_change_password === true); + expect(stamps.length).toBe(1); + expect(m.noteMustChangePasswordIssued).toHaveBeenCalled(); + + // Breakdown surfaced on the summary — one invite, one fallback. + expect(data.summary.delivery).toEqual({ emailInvite: 1, smsInvite: 0, temporary: 1 }); + expectNoPasswordLeak(m, [data.rows[1].temporaryPassword]); + }); + + it('uses the SMS invite path for phone-only rows when SMS is wired', async () => { + const m = makeDeps({ phoneEnabled: true, emailAvailable: true, smsInviteAvailable: true }); + const res = await runAdminImportUsers( + m.deps, + makeRequest({ + passwordPolicy: 'auto', format: 'json', + rows: [ + { email: 'a@x.co' }, + { phone_number: '+86 138 0000 0002' }, + ], + }), + ACTOR, + ); + const data = res.body.data as any; + expect(data.summary.created).toBe(2); + // Email row → reset email; phone-only row → SMS invite. No temp passwords. + expect(m.requestPasswordReset).toHaveBeenCalledTimes(1); + expect(m.sendInviteSms).toHaveBeenCalledTimes(1); + expect(m.sendInviteSms.mock.calls[0][0]).toBe('+8613800000002'); + expect(data.rows.every((r: any) => r.temporaryPassword === undefined)).toBe(true); + expect(data.rows[0].delivery).toBe('email'); + expect(data.rows[1].delivery).toBe('sms'); + expect(data.summary.delivery).toEqual({ emailInvite: 1, smsInvite: 1, temporary: 0 }); + }); + + it('never rejects for missing infrastructure — with no channels wired every row degrades to temporary', async () => { + const m = makeDeps({ phoneEnabled: true, emailAvailable: false, smsInviteAvailable: false }); + const res = await runAdminImportUsers( + m.deps, + makeRequest({ + passwordPolicy: 'auto', format: 'json', + rows: [ + { email: 'a@x.co' }, + { phone_number: '+8613800000003' }, + ], + }), + ACTOR, + ); + // Contrast with `invite`, which 400s here. `auto` provisions everyone. + expect(res.status).toBe(200); + const data = res.body.data as any; + expect(data.summary.created).toBe(2); + expect(m.requestPasswordReset).not.toHaveBeenCalled(); + expect(m.sendInviteSms).not.toHaveBeenCalled(); + expect(data.rows.every((r: any) => typeof r.temporaryPassword === 'string')).toBe(true); + expect(data.rows.every((r: any) => r.delivery === 'temporary')).toBe(true); + expect(data.summary.delivery).toEqual({ emailInvite: 0, smsInvite: 0, temporary: 2 }); + expectNoPasswordLeak(m, data.rows.map((r: any) => r.temporaryPassword)); + }); + + it('surfaces INVITE_EMAIL_FAILED (no rollback) when a chosen email invite fails', async () => { + const m = makeDeps({ resetFails: true }); + const res = await runAdminImportUsers( + m.deps, + makeRequest({ passwordPolicy: 'auto', format: 'json', rows: [{ email: 'a@x.co' }] }), + ACTOR, + ); + const row = (res.body.data as any).rows[0]; + expect(row.ok).toBe(true); + expect(row.action).toBe('created'); + expect(row.code).toBe('INVITE_EMAIL_FAILED'); + expect(row.delivery).toBe('email'); + // A failed invite is NOT silently downgraded to a temporary password. + expect(row.temporaryPassword).toBeUndefined(); + }); + + it('writes nothing on dryRun but still projects the per-row plan', async () => { + const m = makeDeps({ phoneEnabled: true, emailAvailable: true, smsInviteAvailable: false }); + const res = await runAdminImportUsers( + m.deps, + makeRequest({ + passwordPolicy: 'auto', dryRun: true, format: 'json', + rows: [{ email: 'a@x.co' }, { phone_number: '+8613800000006' }], + }), + ACTOR, + ); + expect(res.status).toBe(200); + expect(m.createUser).not.toHaveBeenCalled(); + expect(m.requestPasswordReset).not.toHaveBeenCalled(); + expect(m.sendInviteSms).not.toHaveBeenCalled(); + // dryRun does not deliver, so no per-row delivery is stamped. + expect((res.body.data as any).summary.delivery).toEqual({ emailInvite: 0, smsInvite: 0, temporary: 0 }); + }); +}); + describe('runAdminImportUsers — upsert', () => { it('matches by email: updates profile fields only, never credentials or email', async () => { const m = makeDeps({ diff --git a/packages/plugins/plugin-auth/src/admin-import-users.ts b/packages/plugins/plugin-auth/src/admin-import-users.ts index 0adaab013f..e14634056e 100644 --- a/packages/plugins/plugin-auth/src/admin-import-users.ts +++ b/packages/plugins/plugin-auth/src/admin-import-users.ts @@ -13,29 +13,45 @@ * engine via `runImport`) but swaps the write path for an identity-specific * `ImportProtocolLike` whose `createData` drives `auth.api.createUser`. * - * Password policies: - * - `none` — THE DEFAULT. No password is set at all: better-auth + * Password policies (per-request `passwordPolicy`, default `auto`): + * - `auto` — THE DEFAULT (#3236). Decides PER ROW, preferring the + * invite path and falling back to a temporary password only + * where a row genuinely can't be reached. A row with a + * deliverable channel — a REAL email + a wired EmailService, + * or a phone + a wired SMS-invite path — is invited, so no + * shared secret ever leaves the server. A row with no + * deliverable channel (placeholder email, phone-only without + * SMS, or an email row when no email service is wired) falls + * back to `temporary`. This shrinks the temporary-password + * blast radius from "the whole batch" to "only the rows with + * no channel", and — unlike `invite` — it never rejects the + * request for missing infrastructure: undeliverable rows just + * degrade to temporary. The per-row outcome is surfaced as + * `rows[].delivery` (`email` | `sms` | `temporary`). + * - `invite` — force the invite path for EVERY row: better-auth's + * reset-password mints the credential account on first set, so + * creation stays credential-less, and a "set your password" + * invitation goes out — a reset-password email for rows with a + * REAL email (requires a wired EmailService), or — #2780 — an + * invitation SMS for phone-only rows (requires a wired, + * deliverable SmsService + the phoneNumber plugin; the user + * first signs in via phone OTP and then sets a password). Rows + * that aren't reachable are FAILED per-row (never silently + * downgraded) — pick this when a temporary-password fallback + * is unacceptable. + * - `temporary` — force the no-infrastructure path (no email, no SMS) for + * every row: each created account gets a generated temporary + * password, `must_change_password` is stamped (403 + * PASSWORD_EXPIRED until changed), and the passwords are + * returned ONCE in the HTTP response, one per row. Never + * persisted, never logged. + * - `none` — identity only: no password, no invitation. better-auth * creates the account without a credential record, and the * user's first sign-in is channel-based (phone OTP, magic - * link, or an email reset link). The Console already - * detects credential-less accounts (`hasLocalPassword()`) - * and offers `set-initial-password`, so users are nudged to - * set a password after their first OTP sign-in. Import's - * job is identity — credentials are the auth domain's. - * - `invite` — like `none` (credential-less creation; better-auth's - * reset-password creates the credential account on first - * set), plus a "set your password" invitation goes out per - * created account: a reset-password email for rows with a - * REAL email (requires a wired EmailService), or — #2780 — - * an invitation SMS for phone-only rows (requires a wired, - * deliverable SmsService + the phoneNumber plugin; the user - * first signs in via phone OTP and then sets a password). - * Rows are validated per-channel reachability. - * - `temporary` — the no-infrastructure fallback (no email, no SMS): each - * created account gets a generated temporary password, - * `must_change_password` is stamped (403 PASSWORD_EXPIRED - * until changed), and the passwords are returned ONCE in the - * HTTP response, one per row. Never persisted, never logged. + * link, or an email reset link). The Console detects + * credential-less accounts (`hasLocalPassword()`) and offers + * `set-initial-password`. Pick this to provision identity now + * and defer all credential delivery. * * Deliberate limits: * - Synchronous only, ≤ IMPORT_USERS_MAX_ROWS rows per request. scrypt @@ -97,9 +113,24 @@ export interface IdentityImportDeps { const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; +export type ImportPasswordPolicy = 'auto' | 'none' | 'invite' | 'temporary'; + +/** + * The delivery decision for a single created row, resolved up front from the + * request policy and the row's own reachability. `auto` picks per row; every + * other policy resolves the same plan for every row. createData acts on + * `kind`; the post-write pass acts on the recorded channel. + */ +type RowPlan = + | { kind: 'none' } + | { kind: 'temporary' } + | { kind: 'invite'; channel: 'email' | 'sms' }; + interface RowIdentity { email?: string; phone?: string; + /** Set on valid rows — how this row's credential is delivered. */ + plan?: RowPlan; invalid?: { code: string; error: string }; } @@ -111,7 +142,7 @@ interface RowIdentity { function resolveRowIdentity( row: Record, opts: { - policy: 'none' | 'invite' | 'temporary'; + policy: ImportPasswordPolicy; phoneEnabled: boolean; emailInviteOk: boolean; smsInviteOk: boolean; @@ -142,15 +173,21 @@ function resolveRowIdentity( if (!hasEmail && !phone) { return { invalid: { code: 'NO_IDENTITY', error: 'Row needs an email or a phone_number' } }; } + + const email = hasEmail ? rawEmail.toLowerCase() : undefined; + const emailDeliverable = hasEmail && opts.emailInviteOk; + const smsDeliverable = !!phone && opts.smsInviteOk; + + // `invite` FAILS rows it can't reach (never a silent downgrade); `auto` + // prefers the invite path but falls back to `temporary` for the same + // unreachable rows instead of failing them (#3236). Validated per row so a + // mixed file only fails / downgrades the rows it must. if (opts.policy === 'invite') { - // Every invite row must be REACHABLE through a wired channel: email rows - // need the email service, phone-only rows the SMS-invite path (#2780). - // Validated per row so a mixed file fails only the rows it must. if (hasEmail && !opts.emailInviteOk) { return { invalid: { code: 'EMAIL_SERVICE_REQUIRED', - error: 'This row\'s invitation needs a configured email service — wire an EmailService or use the temporary policy', + error: 'This row\'s invitation needs a configured email service — wire an EmailService, or use the "auto" (fallback) or "temporary" policy', }, }; } @@ -158,12 +195,45 @@ function resolveRowIdentity( return { invalid: { code: 'INVITE_REQUIRES_EMAIL', - error: 'The invite policy needs a real email for this row — configure SMS delivery (phone OTP) for SMS invitations, or use the temporary policy', + error: 'The invite policy needs a real email for this row — configure SMS delivery (phone OTP) for SMS invitations, or use the "auto" (fallback) or "temporary" policy', }, }; } } - return { email: hasEmail ? rawEmail.toLowerCase() : undefined, phone }; + + let plan: RowPlan; + switch (opts.policy) { + case 'none': + plan = { kind: 'none' }; + break; + case 'temporary': + plan = { kind: 'temporary' }; + break; + case 'invite': + // Reachability validated just above — email rows go email, the rest SMS. + plan = { kind: 'invite', channel: hasEmail ? 'email' : 'sms' }; + break; + default: // 'auto' — invite where deliverable, temporary fallback otherwise. + plan = emailDeliverable + ? { kind: 'invite', channel: 'email' } + : smsDeliverable + ? { kind: 'invite', channel: 'sms' } + : { kind: 'temporary' }; + break; + } + + return { email, phone, plan }; +} + +/** + * Stable per-row lookup key for {@link RowPlan}. A phone-only row's final email + * is a placeholder minted inside createData, so it can't be the key — key email + * rows by their (real, lowercased) email and phone-only rows by phone. + */ +function identityKey(email?: string, phone?: string): string { + if (email) return `e:${email.toLowerCase()}`; + if (phone) return `p:${phone}`; + return ''; } export async function runAdminImportUsers( @@ -178,12 +248,14 @@ export async function runAdminImportUsers( }); // ── Request-level validation ───────────────────────────────────────── - // Default policy: `none` — import provisions identity, not credentials. - // Users first sign in via a channel (phone OTP / magic link / reset link) - // and the Console's hasLocalPassword() flow nudges them to set a password. - const policy = body?.passwordPolicy === undefined ? 'none' : body.passwordPolicy; - if (policy !== 'none' && policy !== 'invite' && policy !== 'temporary') { - return fail(400, 'invalid_request', 'passwordPolicy must be "none" (default), "invite", or "temporary"'); + // Default policy: `auto` (#3236) — prefer the invite path (no shared secret + // leaves the server) and fall back to a temporary password only for rows + // with no deliverable channel. Robust to whatever infra is (or isn't) wired: + // it never rejects for missing email/SMS, it just degrades those rows to + // temporary. `none` is still available for identity-only imports. + const policy: ImportPasswordPolicy = body?.passwordPolicy === undefined ? 'auto' : body.passwordPolicy; + if (policy !== 'auto' && policy !== 'none' && policy !== 'invite' && policy !== 'temporary') { + return fail(400, 'invalid_request', 'passwordPolicy must be "auto" (default), "none", "invite", or "temporary"'); } const mode = body?.mode === 'upsert' ? 'upsert' : body?.mode === 'insert' || body?.mode === undefined ? 'insert' : undefined; if (!mode) return fail(400, 'invalid_request', 'mode must be "insert" or "upsert"'); @@ -232,9 +304,13 @@ export async function runAdminImportUsers( // ── Identity pre-validation (runs for dryRun too) ──────────────────── const phoneEnabled = deps.phoneNumberEnabled(); - const results: Array = new Array(prepared.rows.length); + const results: Array = new Array(prepared.rows.length); const validRows: Array> = []; const validIndex: number[] = []; + // Per-row delivery plan, keyed by identity so createData (which sees a + // coerced COPY of the row, not the original object) can look it up. `auto` + // decides each row here; every other policy resolves the same plan for all. + const planByKey = new Map(); for (let i = 0; i < prepared.rows.length; i++) { const row = { ...prepared.rows[i] }; const identity = resolveRowIdentity(row, { policy, phoneEnabled, emailInviteOk, smsInviteOk }); @@ -246,13 +322,14 @@ export async function runAdminImportUsers( delete row.phoneNumber; delete row.phone; if (identity.email) row.email = identity.email; else delete row.email; if (identity.phone) row.phone_number = identity.phone; else delete row.phone_number; + if (identity.plan) planByKey.set(identityKey(identity.email, identity.phone), identity.plan); validRows.push(row); validIndex.push(i); } // ── Identity write protocol (the part generic import must NOT do) ──── const temporaryPasswords = new Map(); // record id → temp password - const createdEmails = new Map(); + const inviteTargets = new Map(); const authApi = await deps.getAuthApi(); if (typeof authApi.createUser !== 'function') { return fail(501, 'not_supported', 'The better-auth admin plugin is not enabled (auth.plugins.admin)'); @@ -281,11 +358,23 @@ export async function runAdminImportUsers( : placeholder ? (phone as string) : email.split('@')[0]; const role: string | undefined = typeof data.role === 'string' && data.role.length > 0 ? data.role : undefined; - // Only the `temporary` policy sets a password. `none`/`invite` create + // The per-row plan was resolved in pre-validation (`auto` decides per + // row; other policies resolve the same plan for all). Key by the row's + // REAL email (placeholder is only minted here) or its phone. + const plan: RowPlan = + planByKey.get(identityKey(placeholder ? undefined : email, phone)) + // Defensive: valid rows always seed a plan. Mirror the request policy. + ?? (policy === 'none' + ? { kind: 'none' } + : policy === 'invite' + ? { kind: 'invite', channel: placeholder ? 'sms' : 'email' } + : { kind: 'temporary' }); + + // Only the temporary path sets a password. Invite / none create // credential-less accounts (better-auth: omitted password → no // credential record); the credential is minted later by the user via // set-initial-password / the reset flow, which creates it on demand. - const password = policy === 'temporary' ? generateTemporaryPassword() : undefined; + const password = plan.kind === 'temporary' ? generateTemporaryPassword() : undefined; const created = await authApi.createUser({ body: { @@ -299,7 +388,7 @@ export async function runAdminImportUsers( const id = created?.user?.id != null ? String(created.user.id) : undefined; if (!id) throw Object.assign(new Error('better-auth returned no user id'), { code: 'CREATE_FAILED' }); - if (policy === 'temporary') { + if (plan.kind === 'temporary') { temporaryPasswords.set(id, password as string); try { await engine.update('sys_user', { id, must_change_password: true }, { context: SYSTEM_CTX } as any); @@ -307,8 +396,8 @@ export async function runAdminImportUsers( } catch (e) { deps.logger?.warn(`[AuthPlugin] import-users: failed to stamp must_change_password for ${id}: ${(e as Error)?.message ?? e}`); } - } else if (policy === 'invite') { - createdEmails.set(id, { email, placeholder, ...(phone ? { phone } : {}) }); + } else if (plan.kind === 'invite') { + inviteTargets.set(id, { channel: plan.channel, email, ...(phone ? { phone } : {}) }); } // `none`: nothing else to do — identity only. return { id }; @@ -354,46 +443,51 @@ export async function runAdminImportUsers( } // ── Post-write phases (skipped on dryRun) ───────────────────────────── + const delivery = { emailInvite: 0, smsInvite: 0, temporary: 0 }; if (!prepared.dryRun) { - // invite: send a set-your-password invitation per created account — - // a reset-password email for real-email rows, an invitation SMS for - // phone-only (placeholder-email) rows (#2780). The SMS carries no - // credential: the user requests their own OTP at first sign-in, which - // keeps the placeholder-email interception logic fully aligned (the - // placeholder address itself is never a delivery target on any channel). - if (policy === 'invite') { - for (const r of results) { - if (!r || r.action !== 'created' || !r.id) continue; - const target = createdEmails.get(r.id); - if (!target) continue; - if (target.placeholder) { - if (!target.phone) continue; // defensive — placeholder rows were validated to carry a phone + // One pass over every created row — each is in exactly one of the two + // maps (or neither, for `none` and updated rows). This single loop serves + // every policy AND `auto`, where invite and temporary rows interleave in + // one batch (#3236). Invited rows get a set-your-password message — a + // reset-password email for real-email rows, an invitation SMS for + // phone-only rows (#2780; the SMS carries no credential — the user + // requests their own OTP at first sign-in). Temporary rows get the + // generated password attached to the response body ONLY. + for (const r of results) { + if (!r || r.action !== 'created' || !r.id) continue; + const invite = inviteTargets.get(r.id); + if (invite) { + if (invite.channel === 'sms') { + r.delivery = 'sms'; + delivery.smsInvite++; + if (!invite.phone) continue; // defensive — SMS rows carry a phone try { - await deps.sendInviteSms(target.phone); + await deps.sendInviteSms(invite.phone); } catch (e) { // The account exists; only the SMS failed. Not a rollback — // remediation is re-sending or an admin set-user-password. r.code = 'INVITE_SMS_FAILED'; r.error = `User created, but the invitation SMS failed: ${((e as Error)?.message ?? String(e)).slice(0, 200)}`; } - continue; - } - try { - await authApi.requestPasswordReset({ body: { email: target.email } }); - } catch (e) { - // The account exists; only the email failed. Not a rollback — - // remediation is re-sending or an admin set-user-password. - r.code = 'INVITE_EMAIL_FAILED'; - r.error = `User created, but the invitation email failed: ${((e as Error)?.message ?? String(e)).slice(0, 200)}`; + } else { + r.delivery = 'email'; + delivery.emailInvite++; + try { + await authApi.requestPasswordReset({ body: { email: invite.email } }); + } catch (e) { + // The account exists; only the email failed. Not a rollback — + // remediation is re-sending or an admin set-user-password. + r.code = 'INVITE_EMAIL_FAILED'; + r.error = `User created, but the invitation email failed: ${((e as Error)?.message ?? String(e)).slice(0, 200)}`; + } } + continue; } - } - // temporary: attach each password to its row — response body ONLY. - if (policy === 'temporary') { - for (const r of results) { - if (r?.action === 'created' && r.id && temporaryPasswords.has(r.id)) { - r.temporaryPassword = temporaryPasswords.get(r.id); - } + const tempPassword = temporaryPasswords.get(r.id); + if (tempPassword !== undefined) { + r.temporaryPassword = tempPassword; + r.delivery = 'temporary'; + delivery.temporary++; } } @@ -411,6 +505,8 @@ export async function runAdminImportUsers( total: prepared.rows.length, created: summary.created, updated: summary.updated, skipped: summary.skipped, errors: summary.errors + preErrors, + // How `auto` (and the fixed policies) split the batch across channels. + delivery, }), }, { context: SYSTEM_CTX } as any); } catch { /* audit table may not exist — never fail the import */ } @@ -430,6 +526,9 @@ export async function runAdminImportUsers( errors, dryRun: prepared.dryRun, passwordPolicy: policy, + // Per-channel split of the created rows — the value of `auto`: how + // many rows were invited vs. fell back to a temporary password. + delivery, mode, matchBy, },