feat(auth): 管理员直建用户 + 手机号登录 + sys_user 批量导入(#2766 V1/V1.5/V2)#2771
Merged
Conversation
V1 — platform admins can create login-capable accounts and (re)set passwords without the email-dependent invite flow: - POST /api/v1/auth/admin/create-user: ADR-0068 admin gate, then a trusted header-less auth.api.createUser call (better-auth pipeline: scrypt hash + credential sys_account). Optional generated temporary password, returned ONCE in the response (never logged / persisted / audited). - POST /api/v1/auth/admin/set-user-password: shadows the stock better-auth route, which only honors the legacy role scalar; mirrors its hash + credential-account core through $context under the ADR-0068 gate, adds generate-password and must-change stamping. - sys_user.must_change_password + computeAuthGate branch (reuses PASSWORD_EXPIRED so the existing 403 seam and Console redirect apply); cleared by stampPasswordChangedAt on any completed password change. Gate activation via a lazy-TTL "any flagged user" cache mirroring _orgMfaCache, primed synchronously on the issuing node. - create_user / upgraded set_user_password actions on sys_user (pure schema; resultDialog reveals the temporary password one time). - features.admin now mirrors enabled.admin (SCIM forces admin on, ADR-0071) instead of advertising false in SCIM deployments. - Explicit best-effort sys_audit_log rows (better-auth writes bypass the ObjectQL hooks plugin-audit subscribes to). V1.5 — better-auth phoneNumber plugin (opt-in auth.plugins.phoneNumber): - Phone+password sign-in only (POST /sign-in/phone-number); sendOTP throws NOT_SUPPORTED until SMS infrastructure exists (tracked separately). - sys_user.phone_number (unique) / phone_number_verified, snake_case via buildPhoneNumberPluginSchema(). - Phone-only accounts get a placeholder email (u-<random>@placeholder.invalid, RFC 2606 — never deliverable, never derived from the phone number so exports/logs can't leak it). Every mail callback (reset / invitation / magic link) refuses placeholder recipients with PLACEHOLDER_EMAIL. - Invitation/magic-link warn logs no longer print bearer URLs outside dev. Closes nothing yet — V2 (bulk import) follows on this branch. Ref #2766 #2758. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016r6eiJzivw1CkwTDSGho1o
…port-prepare.ts (#2766 V2a) Verbatim move out of rest-server.ts (module-private → exported) so the identity import endpoint in plugin-auth can accept payloads byte-identical to the generic /data/:object/import routes without re-implementing the parsers. rest-server.ts imports the extracted module; the two call sites and behavior are unchanged (import-integration tests as the regression gate). Also exports the import building blocks (runImport, coerceRow, buildFieldMetaMap, types) from the package index for cross-package reuse. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016r6eiJzivw1CkwTDSGho1o
…2766 V2) Dedicated sys_user import (re-scoped #2758) — the generic /data import path writes through ObjectQL, bypassing better-auth hashing and never creating the sys_account credential row, so it stays closed for identity. This endpoint reuses the generic import FRAMEWORK (prepareImportRequest payload parsing: rows[]/csv/xlsxBase64 + mapping; runImport row engine: dryRun, upsert matching, row results) with an identity write protocol: - createData drives auth.api.createUser per row (scrypt + credential account); no bulk primitive on purpose. - passwordPolicy 'invite': rows must carry a real email (row-level INVITE_REQUIRES_EMAIL at validation/dryRun time; request-level 400 when no EmailService is wired); accounts are created with a throwaway password and a set-your-password reset email is requested per created row. Email failure marks the row INVITE_EMAIL_FAILED — created, not rolled back. - passwordPolicy 'temporary': generated password per row, returned ONCE in the response; must_change_password stamped (403 PASSWORD_EXPIRED gate from V1). Red-line tests assert no password reaches audit rows, logs, or any persisted surface. - mode insert|upsert, matchBy email|phone (phone requires the V1.5 phoneNumber plugin); upsert updates touch profile fields only (name/phone_number/role) — never email, never credentials, so a re-imported CSV can't silently reset existing users' passwords; blank match keys skip instead of creating duplicates. - Phone-only rows (temporary policy) get placeholder emails; imported password columns are ignored (pre-hashed migration is out of scope). - Sync only, <=500 rows/request (scrypt ~100ms/row); async:true returns ASYNC_NOT_SUPPORTED with batching guidance — an async job is a persistence surface and cannot carry temporary passwords; tracked as a follow-up. No undo by design (undo == bulk-deleting users). - Run-level sys_audit_log row (better-auth writes bypass the ObjectQL hooks plugin-audit subscribes to), password-free. plugin-auth now depends on @objectstack/rest (no cycle; rest does not depend on plugin-auth). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016r6eiJzivw1CkwTDSGho1o
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
📓 Docs Drift CheckThis PR changes 4 package(s): 96 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016r6eiJzivw1CkwTDSGho1o
…ed import Replaces the /^\S+@\S+\.\S+$/ plausibility regex (polynomially backtrackable on adversarial input — js/polynomial-redos, alert 843) with a linear indexOf-based isLikelyEmail() shared by the create-user and import-users endpoints, with an adversarial-input regression test. Removes the unused beforeEach import (alert 844). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016r6eiJzivw1CkwTDSGho1o
This was referenced Jul 10, 2026
This was referenced Jul 10, 2026
os-zhuang
added a commit
that referenced
this pull request
Jul 11, 2026
* docs(auth): document phone sign-in, admin user management, and bulk user import Catches content/docs/permissions/authentication.mdx up with the shipped auth surface (#2766 / PR #2771, SMS follow-up #2780 / PR #2797): - Phone-Number Sign-In section: plugins.phoneNumber flag (and the CLI OS_AUTH_PHONE_NUMBER_ENABLED override), /sign-in/phone-number, the OTP flows' dependency on a wired SMS service (rate-limited), and the placeholder-email semantics for phone-only accounts. - Admin User Management section: /admin/create-user (one-time temporary password + mustChangePassword → 403 PASSWORD_EXPIRED gate), /admin/set-user-password, /admin/import-users (invite/temporary policies incl. the SMS-invite path, upsert profile-field-only rules, 500-row sync cap, no undo). - API endpoint reference: new Phone Number and Admin User Management subsections. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016r6eiJzivw1CkwTDSGho1o * docs(auth): baseline better-auth 'role' boundary refs + regen reference docs check-role-word (ADR-0090 D3) flagged the two new mentions in authentication.mdx — both refer to better-auth's literal `role` scalar (a genuine boundary), so the file is baselined rather than reworded. Also picks up the generated reference docs that lag the merged spec: features.phoneNumber/phoneNumberOtp and the auth-config phoneNumber flag (#2766/#2780), plus component.mdx's relationshipValueField row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016r6eiJzivw1CkwTDSGho1o --------- Co-authored-by: Claude <noreply@anthropic.com>
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements #2766 (re-scoped from #2758) in three staged commits on one branch.
V1 — Admin direct user management(不依赖邮件的建号路径)
POST /api/v1/auth/admin/create-user— ADR-0068 platform-admin gate, then a trusted header-lessauth.api.createUsercall so the account goes through the better-auth pipeline (scrypt hash + credentialsys_account). Supports an explicit password (complexity-checked) or a generated temporary password returned once in the response.POST /api/v1/auth/admin/set-user-password— intentionally shadows the stock better-auth route (registered before the catch-all): the stock handler only honors the legacyrole === 'admin'scalar, which ADR-0068 platform admins may not carry. Mirrors the stock hash + credential-account core through$context, adds generate-password + must-change stamping.sys_user.must_change_password+ acomputeAuthGate()branch reusing thePASSWORD_EXPIREDcode, so the existing 403 transport seams and the Console change-password redirect apply unchanged (ADR-0069). Cleared bystampPasswordChangedAton any completed password change. Gate activation uses a lazy-TTL "any flagged user" cache mirroring_orgMfaCache— zero hot-path cost for deployments that never use the feature; primed synchronously on the issuing node.create_useraction + upgradedset_user_passwordaction onsys_user(pure schema;resultDialogone-shot reveal for the temporary password; zero frontend code).features.adminnow mirrorsenabled.admin(SCIM forces the admin plugin on per ADR-0071; previously advertisedfalsein SCIM deployments).sys_audit_logrows — better-auth writes bypass the ObjectQL lifecycle hooks plugin-audit subscribes to.V1.5 — phoneNumber plugin + placeholder emails(无邮箱员工可建号可登录)
phoneNumberplugin behind opt-inauth.plugins.phoneNumber; phone+password sign-in only (POST /sign-in/phone-number).sendOTPthrowsNOT_SUPPORTED— the OTP surface needs SMS infrastructure tracked separately.sys_user.phone_number(unique) /phone_number_verified, snake_case-mapped viabuildPhoneNumberPluginSchema().u-<random base32>@placeholder.invalid— RFC 2606 reserved TLD (physically undeliverable), local part never derived from the phone number (sys_userhasexportCsvenabled; the email column spreads). Every mail callback (reset / invitation / magic link) refuses placeholder recipients with an explicitPLACEHOLDER_EMAILerror.V2 —
POST /api/v1/auth/admin/import-users(身份专用批量导入)prepareImportRequest(rows[]/csv/xlsxBase64 + mapping; extracted from rest-server.ts in its own refactor commit, behavior unchanged) and therunImportrow engine (dryRun / upsert matching / row results) — with an identityImportProtocolLikewhosecreateDatadrivesauth.api.createUserper row. The generic/data/sys_user/importpath stays closed (ObjectQL direct writes can't produce credentials).passwordPolicy: 'invite'— rows need a real email (row-levelINVITE_REQUIRES_EMAILat validation/dryRun; request-level 400 without an EmailService); created accounts get a set-your-password reset email; email failure marks the rowINVITE_EMAIL_FAILED(created, not rolled back).passwordPolicy: 'temporary'— per-row generated passwords returned once in the response;must_change_passwordstamped. Red-line tests assert no password ever reaches audit rows, logs, or any persisted surface.mode: insert|upsert,matchBy: email|phone; upsert updates touch profile fields only (name/phone_number/role) — never email, never credentials — so a re-imported CSV can't silently reset existing users' passwords. Blank match keys skip instead of creating duplicates. Importedpasswordcolumns are ignored (pre-hashed migration is explicitly out of scope, per feat(auth): 管理员直建用户 + 手机号登录 + sys_user 批量导入 —— 分阶段实施(#2758 重排) #2766).async: truereturnsASYNC_NOT_SUPPORTEDwith batching guidance — an async job result is a persistence surface and cannot carry temporary passwords; the async invite-flow job is a tracked follow-up. No undo by design (undo = bulk-deleting users).Testing
plugin-auth339, incl. 14 new import tests + red-line no-password-leak assertions;rest227 with the existing import-integration suite as the extraction regression gate;spec6676;platform-objects71) — all green viaturbo run test.tsc --noEmitclean for plugin-auth; rest has two pre-existing unrelated errors inpackage-routes.ts(untouched).Runtime-verify notes for reviewers
auth.api.createUserheader-less trusted-call semantics andrequestPasswordResetwere verified against the installedbetter-auth@1.6.23dist.data: { phoneNumber }on createUser persisting the plugin schema field is the documented carrier for plugin user-model fields; worth one end-to-end smoke on a real DB alongside themust_change_password403 → change-password → clear loop.Ref #2766, #2758. Related: #2756 (username sign-in, orthogonal), #2504/#2460 (generic import framework, reused here).
🤖 Generated with Claude Code
https://claude.ai/code/session_016r6eiJzivw1CkwTDSGho1o
Generated by Claude Code