Skip to content

Commit 1785d04

Browse files
Copilotlstein
andcommitted
Add user management UI and backend API endpoints
Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Fix user management feedback: cancel/back navigation, system user filter, tooltip fix Co-authored-by: lstein <111189+lstein@users.noreply.github.com> Make Back button on User Management page more prominent Co-authored-by: lstein <111189+lstein@users.noreply.github.com>
1 parent 445c6a3 commit 1785d04

10 files changed

Lines changed: 1369 additions & 23 deletions

File tree

invokeai/app/api/routers/auth.py

Lines changed: 269 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
11
"""Authentication endpoints."""
22

3+
import secrets
4+
import string
35
from datetime import timedelta
46
from typing import Annotated
57

6-
from fastapi import APIRouter, Body, HTTPException, status
8+
from fastapi import APIRouter, Body, HTTPException, Path, status
79
from pydantic import BaseModel, Field, field_validator
810

9-
from invokeai.app.api.auth_dependencies import CurrentUser
11+
from invokeai.app.api.auth_dependencies import AdminUser, CurrentUser
1012
from invokeai.app.api.dependencies import ApiDependencies
1113
from invokeai.app.services.auth.token_service import TokenData, create_access_token
12-
from invokeai.app.services.users.users_common import UserCreateRequest, UserDTO, validate_email_with_special_domains
14+
from invokeai.app.services.users.users_common import (
15+
UserCreateRequest,
16+
UserDTO,
17+
UserUpdateRequest,
18+
validate_email_with_special_domains,
19+
)
1320

1421
auth_router = APIRouter(prefix="/v1/auth", tags=["authentication"])
1522

@@ -246,3 +253,262 @@ async def setup_admin(
246253
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
247254

248255
return SetupResponse(success=True, user=user)
256+
257+
258+
# ---------------------------------------------------------------------------
259+
# User management models
260+
# ---------------------------------------------------------------------------
261+
262+
_PASSWORD_ALPHABET = string.ascii_letters + string.digits + string.punctuation
263+
264+
265+
class AdminUserCreateRequest(BaseModel):
266+
"""Request body for admin to create a new user."""
267+
268+
email: str = Field(description="User email address")
269+
display_name: str | None = Field(default=None, description="Display name")
270+
password: str = Field(description="User password")
271+
is_admin: bool = Field(default=False, description="Whether user should have admin privileges")
272+
273+
@field_validator("email")
274+
@classmethod
275+
def validate_email(cls, v: str) -> str:
276+
"""Validate email address, allowing special-use domains."""
277+
return validate_email_with_special_domains(v)
278+
279+
280+
class AdminUserUpdateRequest(BaseModel):
281+
"""Request body for admin to update any user."""
282+
283+
display_name: str | None = Field(default=None, description="Display name")
284+
password: str | None = Field(default=None, description="New password")
285+
is_admin: bool | None = Field(default=None, description="Whether user should have admin privileges")
286+
is_active: bool | None = Field(default=None, description="Whether user account should be active")
287+
288+
289+
class UserProfileUpdateRequest(BaseModel):
290+
"""Request body for a user to update their own profile."""
291+
292+
display_name: str | None = Field(default=None, description="New display name")
293+
current_password: str | None = Field(default=None, description="Current password (required when changing password)")
294+
new_password: str | None = Field(default=None, description="New password")
295+
296+
297+
class GeneratePasswordResponse(BaseModel):
298+
"""Response containing a generated password."""
299+
300+
password: str = Field(description="Generated strong password")
301+
302+
303+
# ---------------------------------------------------------------------------
304+
# User management endpoints
305+
# ---------------------------------------------------------------------------
306+
307+
308+
@auth_router.get("/generate-password", response_model=GeneratePasswordResponse)
309+
async def generate_password(
310+
current_user: CurrentUser,
311+
) -> GeneratePasswordResponse:
312+
"""Generate a strong random password.
313+
314+
Returns a cryptographically secure random password of 16 characters
315+
containing uppercase, lowercase, digits, and punctuation.
316+
"""
317+
# Ensure the generated password always meets strength requirements:
318+
# at least one uppercase, one lowercase, one digit, one special char.
319+
while True:
320+
password = "".join(secrets.choice(_PASSWORD_ALPHABET) for _ in range(16))
321+
if (
322+
any(c.isupper() for c in password)
323+
and any(c.islower() for c in password)
324+
and any(c.isdigit() for c in password)
325+
):
326+
return GeneratePasswordResponse(password=password)
327+
328+
329+
@auth_router.get("/users", response_model=list[UserDTO])
330+
async def list_users(
331+
current_user: AdminUser,
332+
) -> list[UserDTO]:
333+
"""List all users. Requires admin privileges.
334+
335+
The internal 'system' user (created for backward compatibility) is excluded
336+
from the results since it cannot be managed through this interface.
337+
338+
Returns:
339+
List of all real users (system user excluded)
340+
"""
341+
user_service = ApiDependencies.invoker.services.users
342+
return [u for u in user_service.list_users() if u.user_id != "system"]
343+
344+
345+
@auth_router.post("/users", response_model=UserDTO, status_code=status.HTTP_201_CREATED)
346+
async def create_user(
347+
request: Annotated[AdminUserCreateRequest, Body(description="New user details")],
348+
current_user: AdminUser,
349+
) -> UserDTO:
350+
"""Create a new user. Requires admin privileges.
351+
352+
Args:
353+
request: New user details
354+
355+
Returns:
356+
The created user
357+
358+
Raises:
359+
HTTPException: 400 if email already exists or password is weak
360+
"""
361+
user_service = ApiDependencies.invoker.services.users
362+
try:
363+
user_data = UserCreateRequest(
364+
email=request.email,
365+
display_name=request.display_name,
366+
password=request.password,
367+
is_admin=request.is_admin,
368+
)
369+
return user_service.create(user_data)
370+
except ValueError as e:
371+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
372+
373+
374+
@auth_router.get("/users/{user_id}", response_model=UserDTO)
375+
async def get_user(
376+
user_id: Annotated[str, Path(description="User ID")],
377+
current_user: AdminUser,
378+
) -> UserDTO:
379+
"""Get a user by ID. Requires admin privileges.
380+
381+
Args:
382+
user_id: The user ID
383+
384+
Returns:
385+
The user
386+
387+
Raises:
388+
HTTPException: 404 if user not found
389+
"""
390+
user_service = ApiDependencies.invoker.services.users
391+
user = user_service.get(user_id)
392+
if user is None:
393+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
394+
return user
395+
396+
397+
@auth_router.patch("/users/{user_id}", response_model=UserDTO)
398+
async def update_user(
399+
user_id: Annotated[str, Path(description="User ID")],
400+
request: Annotated[AdminUserUpdateRequest, Body(description="User fields to update")],
401+
current_user: AdminUser,
402+
) -> UserDTO:
403+
"""Update a user. Requires admin privileges.
404+
405+
Args:
406+
user_id: The user ID
407+
request: Fields to update
408+
409+
Returns:
410+
The updated user
411+
412+
Raises:
413+
HTTPException: 400 if password is weak
414+
HTTPException: 404 if user not found
415+
"""
416+
user_service = ApiDependencies.invoker.services.users
417+
try:
418+
changes = UserUpdateRequest(
419+
display_name=request.display_name,
420+
password=request.password,
421+
is_admin=request.is_admin,
422+
is_active=request.is_active,
423+
)
424+
return user_service.update(user_id, changes)
425+
except ValueError as e:
426+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
427+
428+
429+
@auth_router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
430+
async def delete_user(
431+
user_id: Annotated[str, Path(description="User ID")],
432+
current_user: AdminUser,
433+
) -> None:
434+
"""Delete a user. Requires admin privileges.
435+
436+
Admins can delete any user including other admins, but cannot delete the last
437+
remaining admin.
438+
439+
Args:
440+
user_id: The user ID
441+
442+
Raises:
443+
HTTPException: 400 if attempting to delete the last admin
444+
HTTPException: 404 if user not found
445+
"""
446+
user_service = ApiDependencies.invoker.services.users
447+
user = user_service.get(user_id)
448+
if user is None:
449+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
450+
451+
# Prevent deleting the last active admin
452+
if user.is_admin and user.is_active and user_service.count_admins() <= 1:
453+
raise HTTPException(
454+
status_code=status.HTTP_400_BAD_REQUEST,
455+
detail="Cannot delete the last administrator",
456+
)
457+
458+
try:
459+
user_service.delete(user_id)
460+
except ValueError as e:
461+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
462+
463+
464+
@auth_router.patch("/me", response_model=UserDTO)
465+
async def update_current_user(
466+
request: Annotated[UserProfileUpdateRequest, Body(description="Profile fields to update")],
467+
current_user: CurrentUser,
468+
) -> UserDTO:
469+
"""Update the current user's own profile.
470+
471+
To change the password, both ``current_password`` and ``new_password`` must
472+
be provided. The current password is verified before the change is applied.
473+
474+
Args:
475+
request: Profile fields to update
476+
current_user: The authenticated user
477+
478+
Returns:
479+
The updated user
480+
481+
Raises:
482+
HTTPException: 400 if current password is incorrect or new password is weak
483+
HTTPException: 404 if user not found
484+
"""
485+
user_service = ApiDependencies.invoker.services.users
486+
487+
# Verify current password when attempting a password change
488+
if request.new_password is not None:
489+
if not request.current_password:
490+
raise HTTPException(
491+
status_code=status.HTTP_400_BAD_REQUEST,
492+
detail="Current password is required to set a new password",
493+
)
494+
495+
# Re-authenticate to verify the current password
496+
user = user_service.get(current_user.user_id)
497+
if user is None:
498+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
499+
500+
authenticated = user_service.authenticate(user.email, request.current_password)
501+
if authenticated is None:
502+
raise HTTPException(
503+
status_code=status.HTTP_400_BAD_REQUEST,
504+
detail="Current password is incorrect",
505+
)
506+
507+
try:
508+
changes = UserUpdateRequest(
509+
display_name=request.display_name,
510+
password=request.new_password,
511+
)
512+
return user_service.update(current_user.user_id, changes)
513+
except ValueError as e:
514+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e

invokeai/app/services/users/users_base.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,12 @@ def list_users(self, limit: int = 100, offset: int = 0) -> list[UserDTO]:
124124
List of users
125125
"""
126126
pass
127+
128+
@abstractmethod
129+
def count_admins(self) -> int:
130+
"""Count active admin users.
131+
132+
Returns:
133+
The number of active admin users
134+
"""
135+
pass

invokeai/app/services/users/users_default.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,3 +249,10 @@ def list_users(self, limit: int = 100, offset: int = 0) -> list[UserDTO]:
249249
)
250250
for row in rows
251251
]
252+
253+
def count_admins(self) -> int:
254+
"""Count active admin users."""
255+
with self._db.transaction() as cursor:
256+
cursor.execute("SELECT COUNT(*) FROM users WHERE is_admin = TRUE AND is_active = TRUE")
257+
row = cursor.fetchone()
258+
return int(row[0]) if row else 0

0 commit comments

Comments
 (0)