Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/admin-user-management-and-import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
"@objectstack/plugin-auth": minor
"@objectstack/platform-objects": minor
"@objectstack/rest": minor
"@objectstack/spec": minor
---

feat(auth): admin direct user management, phone sign-in, and identity bulk import (#2766, re-scoped #2758)

`sys_user` is managed by better-auth and its generic CRUD is suppressed, so
until now the only way to add a teammate was the email-dependent invite flow.
This ships three staged capabilities:

- **Admin direct user management** — `POST /api/v1/auth/admin/create-user`
and a wrapped `POST /api/v1/auth/admin/set-user-password` (ADR-0068
platform-admin gate; better-auth pipeline so credentials are real). Optional
generated temporary password (returned once, never persisted or logged) and
a new `sys_user.must_change_password` flag enforced through the ADR-0069
authGate (`403 PASSWORD_EXPIRED` until the user changes it). New
`create_user` action and upgraded `set_user_password` action on the Users
list — pure schema, no frontend changes.
- **Phone sign-in (opt-in `auth.plugins.phoneNumber`)** — better-auth
phoneNumber plugin, phone+password only (`POST /sign-in/phone-number`);
OTP flows stay off until SMS infrastructure exists. Adds
`sys_user.phone_number` (unique) / `phone_number_verified`. Phone-only
accounts get an undeliverable placeholder email
(`u-<random>@placeholder.invalid`, never derived from the phone number);
all auth mail callbacks refuse placeholder recipients.
- **Identity bulk import** — `POST /api/v1/auth/admin/import-users` accepts
the same payloads as the generic import routes (rows/csv/xlsx, dryRun,
upsert by email or phone) but writes every row through better-auth.
Password policies: `invite` (reset-link email per created user; requires an
EmailService) and `temporary` (per-row one-time passwords + forced change).
Sync only, ≤500 rows per request; no undo; upsert updates touch profile
fields only and can never reset an existing user's password.
`prepareImportRequest` and the CSV/xlsx parsers moved from rest-server.ts
to an exported `import-prepare.ts` module (behavior unchanged).
130 changes: 129 additions & 1 deletion packages/platform-objects/src/identity/sys-user.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,20 +114,105 @@ export const SysUser = ObjectSchema.create({
successMessage: 'Account unlocked',
refreshAfter: true,
},
{
// #2766 V1 — a platform admin can add a login-capable teammate without
// the email-dependent invite flow. Hits the plugin-auth wrapper route
// (NOT better-auth's stock /admin/create-user): the wrapper runs the
// ADR-0068 admin gate, drives the better-auth pipeline (scrypt hash +
// credential sys_account), stamps must_change_password, and — when
// "Generate temporary password" is picked — returns the password ONCE
// in the response for the result dialog. It is never persisted or logged.
name: 'create_user',
label: 'Create User',
icon: 'user-plus',
variant: 'secondary',
locations: ['list_toolbar'],
type: 'api',
target: '/api/v1/auth/admin/create-user',
visible: 'features.admin == true',
successMessage: 'User created',
refreshAfter: true,
params: [
// The endpoint requires email OR phone (phone-only users get a
// placeholder address; requires auth.plugins.phoneNumber).
{ field: 'email', required: false },
{
name: 'phoneNumber',
label: 'Phone Number',
type: 'text',
required: false,
helpText: 'Sign-in phone number (E.164, e.g. +8613800000000). Required when no email is given.',
},
{ field: 'name', required: false },
{
name: 'generatePassword',
label: 'Generate Temporary Password',
type: 'boolean',
required: false,
defaultValue: true,
},
{ name: 'password', label: 'Password (leave empty to generate)', type: 'text', required: false },
{
name: 'mustChangePassword',
label: 'Require Password Change On First Login',
type: 'boolean',
required: false,
defaultValue: true,
},
],
resultDialog: {
title: 'User Created',
description:
'Copy the temporary password now — it is shown only once and never stored.',
acknowledge: 'I have saved this password',
fields: [
{ path: 'user.email', label: 'Email', format: 'text' },
{ path: 'temporaryPassword', label: 'Temporary Password', format: 'secret' },
],
},
},
{
name: 'set_user_password',
label: 'Set Password',
icon: 'key-round',
variant: 'secondary',
locations: ['list_item'],
type: 'api',
// #2766 V1 — same path as better-auth's stock route, but served by the
// plugin-auth wrapper registered ahead of the catch-all: it accepts the
// ADR-0068 platform-admin signals (the stock handler only honors the
// legacy role scalar), can mint a temporary password, and stamps
// must_change_password.
target: '/api/v1/auth/admin/set-user-password',
recordIdParam: 'userId',
successMessage: 'Password updated',
refreshAfter: false,
params: [
{ name: 'newPassword', label: 'New Password', type: 'text', required: true },
{
name: 'generatePassword',
label: 'Generate Temporary Password',
type: 'boolean',
required: false,
defaultValue: false,
},
{ name: 'newPassword', label: 'New Password (leave empty to generate)', type: 'text', required: false },
{
name: 'mustChangePassword',
label: 'Require Password Change On Next Login',
type: 'boolean',
required: false,
defaultValue: true,
},
],
resultDialog: {
title: 'Password Updated',
description:
'If a temporary password was generated, copy it now — it is shown only once and never stored.',
acknowledge: 'Done',
fields: [
{ path: 'temporaryPassword', label: 'Temporary Password', format: 'secret' },
],
},
},
{
name: 'set_user_role',
Expand Down Expand Up @@ -460,6 +545,46 @@ export const SysUser = ObjectSchema.create({
description: 'Timestamp of the last password change. Backs password_expiry_days; system-managed.',
}),

// #2766 V1.5 — phone-number sign-in identifier (better-auth phoneNumber
// plugin, mapped via buildPhoneNumberPluginSchema). Unique when present;
// null for email-only accounts. Written through better-auth, not CRUD.
phone_number: Field.text({
label: 'Phone Number',
required: false,
readonly: true,
searchable: true,
maxLength: 32,
group: 'Account',
description:
'Sign-in phone number (E.164 recommended). Unique per user; managed by ' +
'better-auth when the phoneNumber plugin is enabled.',
}),

phone_number_verified: Field.boolean({
label: 'Phone Verified',
defaultValue: false,
readonly: true,
group: 'Account',
description:
'Whether the phone number has been verified (OTP verification requires ' +
'SMS infrastructure; false until that ships). System-managed.',
}),

// #2766 V1 — admin-issued "must change password on next sign-in" flag.
// Set by the /admin/create-user and /admin/set-user-password routes when
// a temporary password is issued; enforced through the ADR-0069 authGate
// (surfaces as PASSWORD_EXPIRED); cleared by stampPasswordChangedAt once
// the user completes a password change.
must_change_password: Field.boolean({
label: 'Must Change Password',
defaultValue: false,
readonly: true,
group: 'Admin',
description:
'When true, the user is blocked (403 PASSWORD_EXPIRED) until they change ' +
'their password. Stamped by the admin user-management routes; system-managed.',
}),

// ADR-0069 D3 — when enforced MFA first applied to this user; starts the
// grace clock. Stamped lazily at session validation; system-managed.
mfa_required_at: Field.datetime({
Expand Down Expand Up @@ -574,6 +699,9 @@ export const SysUser = ObjectSchema.create({
indexes: [
{ fields: ['email'], unique: true },
{ fields: ['created_at'], unique: false },
// #2766 V1.5 — phone sign-in identifier; unique when present (null for
// email-only accounts), also the upsert match key for identity import.
{ fields: ['phone_number'], unique: true },
],

enable: {
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/plugin-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@noble/hashes": "^2.2.0",
"@objectstack/core": "workspace:*",
"@objectstack/platform-objects": "workspace:*",
"@objectstack/rest": "workspace:*",
"@objectstack/spec": "workspace:*",
"@objectstack/types": "workspace:*",
"better-auth": "^1.6.23",
Expand Down
Loading