feat: user management — admin create, password reset, archive/delete, quick invite

Phase 1: must_change_password enforcement + change password endpoint/page
Phase 2: Admin user creation (M365-style) with temp password
Phase 3: Password reset (self-service forgot + admin-triggered)
Phase 4: User archive (soft delete) + hard delete with precheck
Phase 5: Quick invite from admin Users page

Also fixes:
- Auto-create subscription for accounts missing one
- Hard delete precheck ignores sole-member personal accounts
- Seed script patches tree nodes for validation compliance

Migrations: 031 (must_change_password), 032 (password_reset_tokens), 033 (user soft delete)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-13 01:42:51 -05:00
parent b8f25f19eb
commit ad59446332
32 changed files with 3064 additions and 38 deletions

View File

@@ -0,0 +1,28 @@
"""add must_change_password to users
Revision ID: 031
Revises: 030
Create Date: 2026-02-12
Adds must_change_password boolean column to users table.
When True, user is required to change their password before accessing the app.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '031'
down_revision: Union[str, None] = '030'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('users', sa.Column('must_change_password', sa.Boolean(), nullable=False, server_default='false'))
def downgrade() -> None:
op.drop_column('users', 'must_change_password')

View File

@@ -0,0 +1,37 @@
"""add password_reset_tokens table
Revision ID: 032
Revises: 031
Create Date: 2026-02-12
New table for DB-backed password reset tokens (single-use, hashed JTI).
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision: str = '032'
down_revision: Union[str, None] = '031'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'password_reset_tokens',
sa.Column('id', UUID(as_uuid=True), primary_key=True),
sa.Column('token_hash', sa.String(64), unique=True, nullable=False, index=True),
sa.Column('user_id', UUID(as_uuid=True), sa.ForeignKey('users.id'), nullable=False, index=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_by_admin_id', UUID(as_uuid=True), sa.ForeignKey('users.id'), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table('password_reset_tokens')

View File

@@ -0,0 +1,32 @@
"""add soft delete to users
Revision ID: 033
Revises: 032
Create Date: 2026-02-12
Adds deleted_at and deleted_by columns to users table for soft delete (archive).
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision: str = '033'
down_revision: Union[str, None] = '032'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('users', sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True))
op.add_column('users', sa.Column('deleted_by', UUID(as_uuid=True), sa.ForeignKey('users.id'), nullable=True))
op.create_index('ix_users_deleted_at', 'users', ['deleted_at'])
def downgrade() -> None:
op.drop_index('ix_users_deleted_at', table_name='users')
op.drop_column('users', 'deleted_by')
op.drop_column('users', 'deleted_at')