This commit is contained in:
2026-05-08 09:06:14 +12:00
parent 1533b5aa9b
commit 9afc3170ff
22 changed files with 2710 additions and 549 deletions
+56 -1
View File
@@ -21,7 +21,7 @@ from app.core.access import (
require_permission,
)
from app.core.config import settings
from app.core.security import issue_token
from app.core.security import hash_password, issue_token, verify_password
from app.db.session import get_db
from app.models.access import Permission, Role, User
@@ -129,6 +129,61 @@ def read_my_permissions(user: User = Depends(get_current_user)):
return sorted(get_user_permissions(user))
class UpdateMeRequest(BaseModel):
name: str | None = None
email: str | None = None
current_password: str | None = None
new_password: str | None = None
@router.patch("/me", response_model=UserSession)
def update_me(
payload: UpdateMeRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Allow an internal user to update their own name, email, or password."""
if payload.new_password:
# Require current password verification before allowing a password change.
# Users who have never set a personal password must supply the shared
# admin password as the current credential.
current_ok = (
verify_password(payload.current_password or "", user.password_hash)
if user.password_hash
else (payload.current_password or "") == settings.admin_password
)
if not current_ok:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Current password is incorrect",
)
if len(payload.new_password) < 8:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="New password must be at least 8 characters",
)
user.password_hash = hash_password(payload.new_password)
if payload.name is not None:
name = payload.name.strip()
if not name:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Name cannot be empty")
user.name = name
if payload.email is not None:
email = payload.email.strip().lower()
if not email or "@" not in email:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid email address")
existing = db.scalar(select(User).where(User.email == email, User.id != user.id))
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email is already in use")
user.email = email
db.commit()
db.refresh(user)
return _serialize_session(user, include_token=True)
# Permission-enforced administrative endpoints. Route bodies should not check
# role names — every gate is the require_permission(...) dependency.
+23 -1
View File
@@ -84,6 +84,28 @@ def ensure_tenant_columns(engine: Engine) -> tuple[str, ...]:
return tuple(added_columns)
# Ad-hoc column additions for tables that pre-existed before a column was
# introduced on the model. Each entry is (table, column, DDL fragment).
_LEGACY_COLUMN_PATCHES: tuple[tuple[str, str, str], ...] = (
("users", "password_hash", "VARCHAR(255)"),
)
def ensure_legacy_columns(engine: Engine) -> tuple[str, ...]:
added: list[str] = []
for table_name, column_name, ddl in _LEGACY_COLUMN_PATCHES:
if not _table_exists(engine, table_name):
continue
if _has_column(engine, table_name, column_name):
continue
with engine.begin() as connection:
connection.execute(
text(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {ddl}")
)
added.append(f"{table_name}.{column_name}")
return tuple(added)
def sync_tenant_ids(engine: Engine) -> dict[str, int]:
existing_tables = set(inspect(engine).get_table_names())
@@ -339,5 +361,5 @@ def sync_tenant_ids(engine: Engine) -> dict[str, int]:
def bootstrap_schema(engine: Engine, metadata: MetaData) -> MigrationReport:
created_tables = ensure_metadata_tables(engine, metadata)
added_columns = ensure_tenant_columns(engine)
added_columns = ensure_tenant_columns(engine) + ensure_legacy_columns(engine)
return MigrationReport(created_tables=created_tables, added_columns=added_columns)