1414 * `ImportProtocolLike` whose `createData` drives `auth.api.createUser`.
1515 *
1616 * Password policies:
17- * - `invite` — every row needs a reachable identity. Accounts are created
18- * with a random throwaway password the user never sees, then
19- * a "set your password" invitation goes out per created
20- * account: a reset-password email for rows with a REAL
21- * email (requires a wired EmailService), or — #2780 — an
22- * invitation SMS for phone-only rows (requires a wired,
17+ * - `none` — THE DEFAULT. No password is set at all: better-auth
18+ * creates the account without a credential record, and the
19+ * user's first sign-in is channel-based (phone OTP, magic
20+ * link, or an email reset link). The Console already
21+ * detects credential-less accounts (`hasLocalPassword()`)
22+ * and offers `set-initial-password`, so users are nudged to
23+ * set a password after their first OTP sign-in. Import's
24+ * job is identity — credentials are the auth domain's.
25+ * - `invite` — like `none` (credential-less creation; better-auth's
26+ * reset-password creates the credential account on first
27+ * set), plus a "set your password" invitation goes out per
28+ * created account: a reset-password email for rows with a
29+ * REAL email (requires a wired EmailService), or — #2780 —
30+ * an invitation SMS for phone-only rows (requires a wired,
2331 * deliverable SmsService + the phoneNumber plugin; the user
2432 * first signs in via phone OTP and then sets a password).
25- * - `temporary` — each created account gets a generated temporary password,
33+ * Rows are validated per-channel reachability.
34+ * - `temporary` — the no-infrastructure fallback (no email, no SMS): each
35+ * created account gets a generated temporary password,
2636 * `must_change_password` is stamped (403 PASSWORD_EXPIRED
2737 * until changed), and the passwords are returned ONCE in the
2838 * HTTP response, one per row. Never persisted, never logged.
@@ -95,7 +105,7 @@ interface RowIdentity {
95105function resolveRowIdentity (
96106 row : Record < string , any > ,
97107 opts : {
98- policy : 'invite' | 'temporary' ;
108+ policy : 'none' | ' invite' | 'temporary' ;
99109 phoneEnabled : boolean ;
100110 emailInviteOk : boolean ;
101111 smsInviteOk : boolean ;
@@ -162,9 +172,12 @@ export async function runAdminImportUsers(
162172 } ) ;
163173
164174 // ── Request-level validation ─────────────────────────────────────────
165- const policy = body ?. passwordPolicy ;
166- if ( policy !== 'invite' && policy !== 'temporary' ) {
167- return fail ( 400 , 'invalid_request' , 'passwordPolicy must be "invite" or "temporary"' ) ;
175+ // Default policy: `none` — import provisions identity, not credentials.
176+ // Users first sign in via a channel (phone OTP / magic link / reset link)
177+ // and the Console's hasLocalPassword() flow nudges them to set a password.
178+ const policy = body ?. passwordPolicy === undefined ? 'none' : body . passwordPolicy ;
179+ if ( policy !== 'none' && policy !== 'invite' && policy !== 'temporary' ) {
180+ return fail ( 400 , 'invalid_request' , 'passwordPolicy must be "none" (default), "invite", or "temporary"' ) ;
168181 }
169182 const mode = body ?. mode === 'upsert' ? 'upsert' : body ?. mode === 'insert' || body ?. mode === undefined ? 'insert' : undefined ;
170183 if ( ! mode ) return fail ( 400 , 'invalid_request' , 'mode must be "insert" or "upsert"' ) ;
@@ -254,24 +267,25 @@ export async function runAdminImportUsers(
254267 const data : Record < string , any > = args ?. data ?? { } ;
255268 const email : string = typeof data . email === 'string' && data . email . length > 0
256269 ? data . email
257- : generatePlaceholderEmail ( ) ; // temporary policy only — invite rows were validated to carry one
270+ : generatePlaceholderEmail ( ) ; // phone- only rows (none/temporary, or invite via SMS)
258271 const placeholder = ! ( typeof data . email === 'string' && data . email . length > 0 ) ;
259272 const phone : string | undefined = typeof data . phone_number === 'string' && data . phone_number . length > 0 ? data . phone_number : undefined ;
260273 const name : string = typeof data . name === 'string' && data . name . trim ( ) . length > 0
261274 ? data . name . trim ( )
262275 : placeholder ? ( phone as string ) : email . split ( '@' ) [ 0 ] ;
263276 const role : string | undefined = typeof data . role === 'string' && data . role . length > 0 ? data . role : undefined ;
264277
265- // Both policies create with a generated password: `temporary` hands it
266- // to the caller; `invite` throws it away (the user sets their own via
267- // the reset link) — the account is never password-less.
268- const password = generateTemporaryPassword ( ) ;
278+ // Only the `temporary` policy sets a password. `none`/`invite` create
279+ // credential-less accounts (better-auth: omitted password → no
280+ // credential record); the credential is minted later by the user via
281+ // set-initial-password / the reset flow, which creates it on demand.
282+ const password = policy === 'temporary' ? generateTemporaryPassword ( ) : undefined ;
269283
270284 const created = await authApi . createUser ( {
271285 body : {
272286 email,
273287 name,
274- password,
288+ ... ( password ? { password } : { } ) ,
275289 ...( role ? { role } : { } ) ,
276290 ...( phone ? { data : { phoneNumber : phone } } : { } ) ,
277291 } ,
@@ -280,16 +294,17 @@ export async function runAdminImportUsers(
280294 if ( ! id ) throw Object . assign ( new Error ( 'better-auth returned no user id' ) , { code : 'CREATE_FAILED' } ) ;
281295
282296 if ( policy === 'temporary' ) {
283- temporaryPasswords . set ( id , password ) ;
297+ temporaryPasswords . set ( id , password as string ) ;
284298 try {
285299 await engine . update ( 'sys_user' , { id, must_change_password : true } , { context : SYSTEM_CTX } as any ) ;
286300 deps . noteMustChangePasswordIssued ( ) ;
287301 } catch ( e ) {
288302 deps . logger ?. warn ( `[AuthPlugin] import-users: failed to stamp must_change_password for ${ id } : ${ ( e as Error ) ?. message ?? e } ` ) ;
289303 }
290- } else {
304+ } else if ( policy === 'invite' ) {
291305 createdEmails . set ( id , { email, placeholder, ...( phone ? { phone } : { } ) } ) ;
292306 }
307+ // `none`: nothing else to do — identity only.
293308 return { id } ;
294309 } ,
295310
0 commit comments