diff --git a/invokeai/app/api/routers/auth.py b/invokeai/app/api/routers/auth.py index 11f2bacdc5c..2e7e49c41e3 100644 --- a/invokeai/app/api/routers/auth.py +++ b/invokeai/app/api/routers/auth.py @@ -1,15 +1,22 @@ """Authentication endpoints.""" +import secrets +import string from datetime import timedelta from typing import Annotated -from fastapi import APIRouter, Body, HTTPException, status +from fastapi import APIRouter, Body, HTTPException, Path, status from pydantic import BaseModel, Field, field_validator -from invokeai.app.api.auth_dependencies import CurrentUser +from invokeai.app.api.auth_dependencies import AdminUser, CurrentUser from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.services.auth.token_service import TokenData, create_access_token -from invokeai.app.services.users.users_common import UserCreateRequest, UserDTO, validate_email_with_special_domains +from invokeai.app.services.users.users_common import ( + UserCreateRequest, + UserDTO, + UserUpdateRequest, + validate_email_with_special_domains, +) auth_router = APIRouter(prefix="/v1/auth", tags=["authentication"]) @@ -246,3 +253,262 @@ async def setup_admin( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e return SetupResponse(success=True, user=user) + + +# --------------------------------------------------------------------------- +# User management models +# --------------------------------------------------------------------------- + +_PASSWORD_ALPHABET = string.ascii_letters + string.digits + string.punctuation + + +class AdminUserCreateRequest(BaseModel): + """Request body for admin to create a new user.""" + + email: str = Field(description="User email address") + display_name: str | None = Field(default=None, description="Display name") + password: str = Field(description="User password") + is_admin: bool = Field(default=False, description="Whether user should have admin privileges") + + @field_validator("email") + @classmethod + def validate_email(cls, v: str) -> str: + """Validate email address, allowing special-use domains.""" + return validate_email_with_special_domains(v) + + +class AdminUserUpdateRequest(BaseModel): + """Request body for admin to update any user.""" + + display_name: str | None = Field(default=None, description="Display name") + password: str | None = Field(default=None, description="New password") + is_admin: bool | None = Field(default=None, description="Whether user should have admin privileges") + is_active: bool | None = Field(default=None, description="Whether user account should be active") + + +class UserProfileUpdateRequest(BaseModel): + """Request body for a user to update their own profile.""" + + display_name: str | None = Field(default=None, description="New display name") + current_password: str | None = Field(default=None, description="Current password (required when changing password)") + new_password: str | None = Field(default=None, description="New password") + + +class GeneratePasswordResponse(BaseModel): + """Response containing a generated password.""" + + password: str = Field(description="Generated strong password") + + +# --------------------------------------------------------------------------- +# User management endpoints +# --------------------------------------------------------------------------- + + +@auth_router.get("/generate-password", response_model=GeneratePasswordResponse) +async def generate_password( + current_user: CurrentUser, +) -> GeneratePasswordResponse: + """Generate a strong random password. + + Returns a cryptographically secure random password of 16 characters + containing uppercase, lowercase, digits, and punctuation. + """ + # Ensure the generated password always meets strength requirements: + # at least one uppercase, one lowercase, one digit, one special char. + while True: + password = "".join(secrets.choice(_PASSWORD_ALPHABET) for _ in range(16)) + if ( + any(c.isupper() for c in password) + and any(c.islower() for c in password) + and any(c.isdigit() for c in password) + ): + return GeneratePasswordResponse(password=password) + + +@auth_router.get("/users", response_model=list[UserDTO]) +async def list_users( + current_user: AdminUser, +) -> list[UserDTO]: + """List all users. Requires admin privileges. + + The internal 'system' user (created for backward compatibility) is excluded + from the results since it cannot be managed through this interface. + + Returns: + List of all real users (system user excluded) + """ + user_service = ApiDependencies.invoker.services.users + return [u for u in user_service.list_users() if u.user_id != "system"] + + +@auth_router.post("/users", response_model=UserDTO, status_code=status.HTTP_201_CREATED) +async def create_user( + request: Annotated[AdminUserCreateRequest, Body(description="New user details")], + current_user: AdminUser, +) -> UserDTO: + """Create a new user. Requires admin privileges. + + Args: + request: New user details + + Returns: + The created user + + Raises: + HTTPException: 400 if email already exists or password is weak + """ + user_service = ApiDependencies.invoker.services.users + try: + user_data = UserCreateRequest( + email=request.email, + display_name=request.display_name, + password=request.password, + is_admin=request.is_admin, + ) + return user_service.create(user_data) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e + + +@auth_router.get("/users/{user_id}", response_model=UserDTO) +async def get_user( + user_id: Annotated[str, Path(description="User ID")], + current_user: AdminUser, +) -> UserDTO: + """Get a user by ID. Requires admin privileges. + + Args: + user_id: The user ID + + Returns: + The user + + Raises: + HTTPException: 404 if user not found + """ + user_service = ApiDependencies.invoker.services.users + user = user_service.get(user_id) + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + return user + + +@auth_router.patch("/users/{user_id}", response_model=UserDTO) +async def update_user( + user_id: Annotated[str, Path(description="User ID")], + request: Annotated[AdminUserUpdateRequest, Body(description="User fields to update")], + current_user: AdminUser, +) -> UserDTO: + """Update a user. Requires admin privileges. + + Args: + user_id: The user ID + request: Fields to update + + Returns: + The updated user + + Raises: + HTTPException: 400 if password is weak + HTTPException: 404 if user not found + """ + user_service = ApiDependencies.invoker.services.users + try: + changes = UserUpdateRequest( + display_name=request.display_name, + password=request.password, + is_admin=request.is_admin, + is_active=request.is_active, + ) + return user_service.update(user_id, changes) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e + + +@auth_router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_user( + user_id: Annotated[str, Path(description="User ID")], + current_user: AdminUser, +) -> None: + """Delete a user. Requires admin privileges. + + Admins can delete any user including other admins, but cannot delete the last + remaining admin. + + Args: + user_id: The user ID + + Raises: + HTTPException: 400 if attempting to delete the last admin + HTTPException: 404 if user not found + """ + user_service = ApiDependencies.invoker.services.users + user = user_service.get(user_id) + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + # Prevent deleting the last active admin + if user.is_admin and user.is_active and user_service.count_admins() <= 1: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot delete the last administrator", + ) + + try: + user_service.delete(user_id) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e + + +@auth_router.patch("/me", response_model=UserDTO) +async def update_current_user( + request: Annotated[UserProfileUpdateRequest, Body(description="Profile fields to update")], + current_user: CurrentUser, +) -> UserDTO: + """Update the current user's own profile. + + To change the password, both ``current_password`` and ``new_password`` must + be provided. The current password is verified before the change is applied. + + Args: + request: Profile fields to update + current_user: The authenticated user + + Returns: + The updated user + + Raises: + HTTPException: 400 if current password is incorrect or new password is weak + HTTPException: 404 if user not found + """ + user_service = ApiDependencies.invoker.services.users + + # Verify current password when attempting a password change + if request.new_password is not None: + if not request.current_password: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Current password is required to set a new password", + ) + + # Re-authenticate to verify the current password + user = user_service.get(current_user.user_id) + if user is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + + authenticated = user_service.authenticate(user.email, request.current_password) + if authenticated is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Current password is incorrect", + ) + + try: + changes = UserUpdateRequest( + display_name=request.display_name, + password=request.new_password, + ) + return user_service.update(current_user.user_id, changes) + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e diff --git a/invokeai/app/services/users/users_base.py b/invokeai/app/services/users/users_base.py index 6587a2aa3ae..5ad66c59832 100644 --- a/invokeai/app/services/users/users_base.py +++ b/invokeai/app/services/users/users_base.py @@ -124,3 +124,12 @@ def list_users(self, limit: int = 100, offset: int = 0) -> list[UserDTO]: List of users """ pass + + @abstractmethod + def count_admins(self) -> int: + """Count active admin users. + + Returns: + The number of active admin users + """ + pass diff --git a/invokeai/app/services/users/users_default.py b/invokeai/app/services/users/users_default.py index 36ccec9e7e2..506ae937f02 100644 --- a/invokeai/app/services/users/users_default.py +++ b/invokeai/app/services/users/users_default.py @@ -249,3 +249,10 @@ def list_users(self, limit: int = 100, offset: int = 0) -> list[UserDTO]: ) for row in rows ] + + def count_admins(self) -> int: + """Count active admin users.""" + with self._db.transaction() as cursor: + cursor.execute("SELECT COUNT(*) FROM users WHERE is_admin = TRUE AND is_active = TRUE") + row = cursor.fetchone() + return int(row[0]) if row else 0 diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index c28df6ee383..3dc9ebe98e5 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -51,7 +51,58 @@ "userMenu": "User Menu", "admin": "Admin", "logout": "Logout", - "adminOnlyFeature": "This feature is only available to administrators." + "adminOnlyFeature": "This feature is only available to administrators.", + "profile": { + "menuItem": "My Profile", + "title": "My Profile", + "email": "Email", + "emailReadOnly": "Email address cannot be changed", + "displayName": "Display Name", + "displayNamePlaceholder": "Your name", + "changePassword": "Change Password", + "currentPassword": "Current Password", + "currentPasswordPlaceholder": "Current password", + "newPassword": "New Password", + "newPasswordPlaceholder": "New password", + "confirmPassword": "Confirm New Password", + "confirmPasswordPlaceholder": "Confirm new password", + "passwordsDoNotMatch": "Passwords do not match", + "saveSuccess": "Profile updated successfully", + "saveFailed": "Failed to save profile. Please try again." + }, + "userManagement": { + "menuItem": "User Management", + "title": "User Management", + "email": "Email", + "emailPlaceholder": "user@example.com", + "displayName": "Display Name", + "displayNamePlaceholder": "Display name", + "password": "Password", + "passwordPlaceholder": "Password", + "newPassword": "New Password", + "newPasswordPlaceholder": "Leave blank to keep current password", + "role": "Role", + "status": "Status", + "actions": "Actions", + "isAdmin": "Administrator", + "user": "User", + "you": "You", + "createUser": "Create User", + "editUser": "Edit User", + "deleteUser": "Delete User", + "deleteConfirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.", + "generatePassword": "Generate Strong Password", + "showPassword": "Show password", + "hidePassword": "Hide password", + "activate": "Activate", + "deactivate": "Deactivate", + "saveFailed": "Failed to save user. Please try again.", + "deleteFailed": "Failed to delete user. Please try again.", + "loadFailed": "Failed to load users.", + "back": "Back", + "cannotDeleteSelf": "You cannot delete your own account", + "cannotDeactivateSelf": "You cannot deactivate your own account" + } }, "boards": { "addBoard": "Add Board", @@ -2413,10 +2464,14 @@ "text": { "font": "Font", "size": "Size", - "lineHeight": "Spacing", - "lineHeightDense": "Dense", - "lineHeightNormal": "Normal", - "lineHeightSpacious": "Spacious" + "bold": "Bold", + "italic": "Italic", + "underline": "Underline", + "strikethrough": "Strikethrough", + "alignLeft": "Align Left", + "alignCenter": "Align Center", + "alignRight": "Align Right", + "px": "px" }, "newCanvasFromImage": "New Canvas from Image", "newImg2ImgCanvasFromImage": "New Img2Img from Image", @@ -2581,18 +2636,6 @@ "colorPicker": "Color Picker", "text": "Text" }, - "text": { - "font": "Font", - "size": "Size", - "bold": "Bold", - "italic": "Italic", - "underline": "Underline", - "strikethrough": "Strikethrough", - "alignLeft": "Align Left", - "alignCenter": "Align Center", - "alignRight": "Align Right", - "px": "px" - }, "filter": { "filter": "Filter", "filters": "Filters", diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx index 678acc7de1f..0f9fb5292b8 100644 --- a/invokeai/frontend/web/src/app/components/App.tsx +++ b/invokeai/frontend/web/src/app/components/App.tsx @@ -7,8 +7,11 @@ import Loading from 'common/components/Loading/Loading'; import { AdministratorSetup } from 'features/auth/components/AdministratorSetup'; import { LoginPage } from 'features/auth/components/LoginPage'; import { ProtectedRoute } from 'features/auth/components/ProtectedRoute'; +import { UserManagement } from 'features/auth/components/UserManagement'; +import { UserProfile } from 'features/auth/components/UserProfile'; import { AppContent } from 'features/ui/components/AppContent'; import { navigationApi } from 'features/ui/layouts/navigation-api'; +import type { ReactNode } from 'react'; import { memo, useEffect } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import { Route, Routes, useNavigate } from 'react-router-dom'; @@ -67,6 +70,13 @@ const SetupChecker = () => { return null; }; +/** Full-page wrapper for user management / profile pages rendered inside the protected area */ +const FullPageWrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +); + const App = () => { return ( @@ -75,6 +85,26 @@ const App = () => { } /> } /> } /> + + + + + + } + /> + + + + + + } + /> string +): { isValid: boolean; message: string } => { + if (password.length === 0) { + return { isValid: true, message: '' }; + } + if (password.length < 8) { + return { isValid: false, message: t('auth.setup.passwordTooShort') }; + } + const hasUpper = /[A-Z]/.test(password); + const hasLower = /[a-z]/.test(password); + const hasDigit = /\d/.test(password); + if (!hasUpper || !hasLower || !hasDigit) { + return { isValid: false, message: t('auth.setup.passwordMissingRequirements') }; + } + return { isValid: true, message: '' }; +}; + +const FORM_GRID_COLUMNS = '120px 1fr'; + +// --------------------------------------------------------------------------- +// Create / Edit user modal +// --------------------------------------------------------------------------- + +type UserFormModalProps = { + isOpen: boolean; + onClose: () => void; + /** When provided, the modal operates in "edit" mode for the given user */ + editUser?: UserDTO | null; +}; + +const UserFormModal = memo(({ isOpen, onClose, editUser }: UserFormModalProps) => { + const { t } = useTranslation(); + const isEdit = !!editUser; + + const [email, setEmail] = useState(editUser?.email ?? ''); + const [displayName, setDisplayName] = useState(editUser?.display_name ?? ''); + const [password, setPassword] = useState(''); + const [isAdmin, setIsAdmin] = useState(editUser?.is_admin ?? false); + const [showPassword, setShowPassword] = useState(false); + const [error, setError] = useState(null); + + const [createUser, { isLoading: isCreating }] = useCreateUserMutation(); + const [updateUser, { isLoading: isUpdating }] = useUpdateUserMutation(); + const [triggerGeneratePassword] = useLazyGeneratePasswordQuery(); + + const isLoading = isCreating || isUpdating; + const passwordValidation = validatePasswordStrength(password, t); + + const handleGeneratePassword = useCallback(async () => { + try { + const result = await triggerGeneratePassword().unwrap(); + setPassword(result.password); + setShowPassword(true); + } catch { + // ignore + } + }, [triggerGeneratePassword]); + + const toggleShowPassword = useCallback(() => { + setShowPassword((v) => !v); + }, []); + + const handleEmailChange = useCallback((e: ChangeEvent) => { + setEmail(e.target.value); + }, []); + + const handleDisplayNameChange = useCallback((e: ChangeEvent) => { + setDisplayName(e.target.value); + }, []); + + const handlePasswordChange = useCallback((e: ChangeEvent) => { + setPassword(e.target.value); + }, []); + + const handleIsAdminChange = useCallback((e: ChangeEvent) => { + setIsAdmin(e.target.checked); + }, []); + + const handleSubmit = useCallback( + async (e: FormEvent) => { + e.preventDefault(); + setError(null); + + if (!isEdit && (!password || !passwordValidation.isValid)) { + return; + } + if (isEdit && password && !passwordValidation.isValid) { + return; + } + + try { + if (isEdit && editUser) { + const updateData: Parameters[0]['data'] = { + display_name: displayName || null, + is_admin: isAdmin, + }; + if (password) { + updateData.password = password; + } + await updateUser({ + userId: editUser.user_id, + data: updateData, + }).unwrap(); + } else { + await createUser({ + email, + display_name: displayName || null, + password, + is_admin: isAdmin, + }).unwrap(); + } + onClose(); + } catch (err) { + const detail = + err && typeof err === 'object' && 'data' in err && typeof (err as { data: unknown }).data === 'object' + ? ((err as { data: { detail?: string } }).data?.detail ?? t('auth.userManagement.saveFailed')) + : t('auth.userManagement.saveFailed'); + setError(detail); + } + }, + [ + isEdit, + editUser, + email, + displayName, + password, + isAdmin, + passwordValidation.isValid, + createUser, + updateUser, + onClose, + t, + ] + ); + + // Reset local state when modal closes + const handleClose = useCallback(() => { + setEmail(editUser?.email ?? ''); + setDisplayName(editUser?.display_name ?? ''); + setPassword(''); + setIsAdmin(editUser?.is_admin ?? false); + setShowPassword(false); + setError(null); + onClose(); + }, [editUser, onClose]); + + return ( + + + +
+ {isEdit ? t('auth.userManagement.editUser') : t('auth.userManagement.createUser')} + + + + {!isEdit && ( + + + + + {t('auth.userManagement.email')} + + + + + + + + )} + + + + + + {t('auth.userManagement.displayName')} + + + + + + + + + 0 && !passwordValidation.isValid} isRequired={!isEdit}> + + + + {isEdit ? t('auth.userManagement.newPassword') : t('auth.userManagement.password')} + + + + + + + + : } + variant="ghost" + size="sm" + onClick={toggleShowPassword} + tabIndex={-1} + /> + + + + {password.length > 0 && !passwordValidation.isValid && ( + {passwordValidation.message} + )} + + + + + + + + + + + + + {t('auth.userManagement.isAdmin')} + + + + {error && ( + + {error} + + )} + + + + + + + +
+
+ ); +}); +UserFormModal.displayName = 'UserFormModal'; + +// --------------------------------------------------------------------------- +// Delete confirmation modal +// --------------------------------------------------------------------------- + +type DeleteUserModalProps = { + isOpen: boolean; + onClose: () => void; + user: UserDTO | null; +}; + +const DeleteUserModal = memo(({ isOpen, onClose, user }: DeleteUserModalProps) => { + const { t } = useTranslation(); + const [deleteUser, { isLoading }] = useDeleteUserMutation(); + const [error, setError] = useState(null); + + const handleDelete = useCallback(async () => { + if (!user) { + return; + } + setError(null); + try { + await deleteUser(user.user_id).unwrap(); + onClose(); + } catch (err) { + const detail = + err && typeof err === 'object' && 'data' in err && typeof (err as { data: unknown }).data === 'object' + ? ((err as { data: { detail?: string } }).data?.detail ?? t('auth.userManagement.deleteFailed')) + : t('auth.userManagement.deleteFailed'); + setError(detail); + } + }, [user, deleteUser, onClose, t]); + + const handleClose = useCallback(() => { + setError(null); + onClose(); + }, [onClose]); + + return ( + + + + {t('auth.userManagement.deleteUser')} + + + + {t('auth.userManagement.deleteConfirm', { + name: user?.display_name ?? user?.email ?? '', + })} + + {error && ( + + {error} + + )} + + + + + + + + ); +}); +DeleteUserModal.displayName = 'DeleteUserModal'; + +// --------------------------------------------------------------------------- +// Inline active/inactive toggle +// Wrapping the Switch in a Box lets the Tooltip track mouse-enter/leave +// correctly; without it the tooltip may not dismiss on mouse-out. +// --------------------------------------------------------------------------- + +const UserStatusToggle = memo(({ user, isCurrentUser }: { user: UserDTO; isCurrentUser: boolean }) => { + const { t } = useTranslation(); + const [updateUser, { isLoading }] = useUpdateUserMutation(); + + const handleChange = useCallback( + async (e: ChangeEvent) => { + await updateUser({ userId: user.user_id, data: { is_active: e.target.checked } }) + .unwrap() + .catch(() => null); + }, + [user.user_id, updateUser] + ); + + const tooltipLabel = isCurrentUser + ? t('auth.userManagement.cannotDeactivateSelf') + : user.is_active + ? t('auth.userManagement.deactivate') + : t('auth.userManagement.activate'); + + return ( + + + + + + ); +}); +UserStatusToggle.displayName = 'UserStatusToggle'; + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export const UserManagement = memo(() => { + const { t } = useTranslation(); + const currentUser = useAppSelector(selectCurrentUser); + const navigate = useNavigate(); + const { data: users, isLoading, error } = useListUsersQuery(); + + const createModal = useDisclosure(); + const editModal = useDisclosure(); + const deleteModal = useDisclosure(); + + const [selectedUser, setSelectedUser] = useState(null); + + const handleBack = useCallback(() => { + navigate(-1); + }, [navigate]); + + const handleEdit = useCallback( + (user: UserDTO) => { + setSelectedUser(user); + editModal.onOpen(); + }, + [editModal] + ); + + const handleDelete = useCallback( + (user: UserDTO) => { + setSelectedUser(user); + deleteModal.onOpen(); + }, + [deleteModal] + ); + + const handleEditClose = useCallback(() => { + editModal.onClose(); + setSelectedUser(null); + }, [editModal]); + + const handleDeleteClose = useCallback(() => { + deleteModal.onClose(); + setSelectedUser(null); + }, [deleteModal]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ {t('auth.userManagement.loadFailed')} +
+ ); + } + + return ( + + + + + {t('auth.userManagement.title')} + + + + + + + + + + + + + + + + + {(users ?? []).map((user) => ( + + ))} + +
{t('auth.userManagement.email')}{t('auth.userManagement.displayName')}{t('auth.userManagement.role')}{t('auth.userManagement.status')}{t('auth.userManagement.actions')}
+
+ + {/* Create user modal */} + + + {/* Edit user modal */} + + + {/* Delete confirmation modal */} + +
+ ); +}); +UserManagement.displayName = 'UserManagement'; + +// --------------------------------------------------------------------------- +// User table row +// --------------------------------------------------------------------------- + +type UserRowProps = { + user: UserDTO; + isCurrentUser: boolean; + onEdit: (user: UserDTO) => void; + onDelete: (user: UserDTO) => void; +}; + +const UserRow = memo(({ user, isCurrentUser, onEdit, onDelete }: UserRowProps) => { + const { t } = useTranslation(); + + const handleEdit = useCallback(() => { + onEdit(user); + }, [user, onEdit]); + + const handleDelete = useCallback(() => { + onDelete(user); + }, [user, onDelete]); + + return ( + + + {user.email} + {isCurrentUser && ( + + {t('auth.userManagement.you')} + + )} + + + {user.display_name ?? '—'} + + + {user.is_admin ? ( + {t('auth.admin')} + ) : ( + {t('auth.userManagement.user')} + )} + + + + + + + + } + variant="ghost" + size="sm" + onClick={handleEdit} + /> + + + } + variant="ghost" + size="sm" + colorScheme="error" + isDisabled={isCurrentUser} + onClick={handleDelete} + /> + + + + + ); +}); +UserRow.displayName = 'UserRow'; diff --git a/invokeai/frontend/web/src/features/auth/components/UserMenu.tsx b/invokeai/frontend/web/src/features/auth/components/UserMenu.tsx index 970c1d75332..d8f598f996b 100644 --- a/invokeai/frontend/web/src/features/auth/components/UserMenu.tsx +++ b/invokeai/frontend/web/src/features/auth/components/UserMenu.tsx @@ -3,7 +3,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { logout, selectCurrentUser } from 'features/auth/store/authSlice'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiSignOutBold, PiUserBold } from 'react-icons/pi'; +import { PiGearBold, PiSignOutBold, PiUserBold, PiUsersBold } from 'react-icons/pi'; import { useNavigate } from 'react-router-dom'; import { useLogoutMutation } from 'services/api/endpoints/auth'; @@ -28,6 +28,14 @@ export const UserMenu = memo(() => { }); }, [dispatch, navigate, logoutMutation]); + const handleProfile = useCallback(() => { + navigate('/profile'); + }, [navigate]); + + const handleUserManagement = useCallback(() => { + navigate('/admin/users'); + }, [navigate]); + if (!user) { return null; } @@ -60,6 +68,14 @@ export const UserMenu = memo(() => { )} + } onClick={handleProfile}> + {t('auth.profile.menuItem')} + + {user.is_admin && ( + } onClick={handleUserManagement}> + {t('auth.userManagement.menuItem')} + + )} } onClick={handleLogout}> {t('auth.logout')} diff --git a/invokeai/frontend/web/src/features/auth/components/UserProfile.tsx b/invokeai/frontend/web/src/features/auth/components/UserProfile.tsx new file mode 100644 index 00000000000..4504698f0ea --- /dev/null +++ b/invokeai/frontend/web/src/features/auth/components/UserProfile.tsx @@ -0,0 +1,390 @@ +import { + Box, + Button, + Center, + Flex, + FormControl, + FormErrorMessage, + FormHelperText, + FormLabel, + Grid, + GridItem, + Heading, + IconButton, + Input, + InputGroup, + InputRightElement, + Spinner, + Text, + Tooltip, + VStack, +} from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { selectAuthToken, selectCurrentUser, setCredentials } from 'features/auth/store/authSlice'; +import type { ChangeEvent, FormEvent } from 'react'; +import { memo, useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiEyeBold, PiEyeSlashBold, PiLightningFill } from 'react-icons/pi'; +import { useNavigate } from 'react-router-dom'; +import { useLazyGeneratePasswordQuery, useUpdateCurrentUserMutation } from 'services/api/endpoints/auth'; + +const validatePasswordStrength = ( + password: string, + t: (key: string) => string +): { isValid: boolean; message: string } => { + if (password.length === 0) { + return { isValid: true, message: '' }; + } + if (password.length < 8) { + return { isValid: false, message: t('auth.setup.passwordTooShort') }; + } + const hasUpper = /[A-Z]/.test(password); + const hasLower = /[a-z]/.test(password); + const hasDigit = /\d/.test(password); + if (!hasUpper || !hasLower || !hasDigit) { + return { isValid: false, message: t('auth.setup.passwordMissingRequirements') }; + } + return { isValid: true, message: '' }; +}; + +const PASSWORD_GRID_COLUMNS = '180px 1fr'; + +export const UserProfile = memo(() => { + const { t } = useTranslation(); + const currentUser = useAppSelector(selectCurrentUser); + const currentToken = useAppSelector(selectAuthToken); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + + const [displayName, setDisplayName] = useState(currentUser?.display_name ?? ''); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [showCurrentPassword, setShowCurrentPassword] = useState(false); + const [showNewPassword, setShowNewPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const [updateCurrentUser, { isLoading }] = useUpdateCurrentUserMutation(); + const [triggerGeneratePassword] = useLazyGeneratePasswordQuery(); + + const newPasswordValidation = validatePasswordStrength(newPassword, t); + + const isPasswordChangeAttempted = newPassword.length > 0 || currentPassword.length > 0; + const passwordsMatch = newPassword.length > 0 && newPassword === confirmPassword; + const isPasswordChangeValid = + !isPasswordChangeAttempted || (currentPassword.length > 0 && newPasswordValidation.isValid && passwordsMatch); + + const handleCancel = useCallback(() => { + navigate(-1); + }, [navigate]); + + const handleGeneratePassword = useCallback(async () => { + try { + const result = await triggerGeneratePassword().unwrap(); + setNewPassword(result.password); + setConfirmPassword(result.password); + setShowNewPassword(true); + setShowConfirmPassword(true); + } catch { + // ignore + } + }, [triggerGeneratePassword]); + + const toggleShowCurrentPassword = useCallback(() => { + setShowCurrentPassword((v) => !v); + }, []); + + const toggleShowNewPassword = useCallback(() => { + setShowNewPassword((v) => !v); + }, []); + + const toggleShowConfirmPassword = useCallback(() => { + setShowConfirmPassword((v) => !v); + }, []); + + const handleDisplayNameChange = useCallback((e: ChangeEvent) => { + setDisplayName(e.target.value); + }, []); + + const handleCurrentPasswordChange = useCallback((e: ChangeEvent) => { + setCurrentPassword(e.target.value); + }, []); + + const handleNewPasswordChange = useCallback((e: ChangeEvent) => { + setNewPassword(e.target.value); + }, []); + + const handleConfirmPasswordChange = useCallback((e: ChangeEvent) => { + setConfirmPassword(e.target.value); + }, []); + + const handleSubmit = useCallback( + async (e: FormEvent) => { + e.preventDefault(); + setErrorMessage(null); + + if (!isPasswordChangeValid) { + return; + } + + try { + const updatePayload: Parameters[0] = { + display_name: displayName || null, + }; + if (newPassword) { + updatePayload.current_password = currentPassword; + updatePayload.new_password = newPassword; + } + const updatedUser = await updateCurrentUser(updatePayload).unwrap(); + + // Refresh the stored user info so the header reflects the new display name + if (currentToken) { + dispatch( + setCredentials({ + token: currentToken, + user: { + user_id: updatedUser.user_id, + email: updatedUser.email, + display_name: updatedUser.display_name ?? null, + is_admin: updatedUser.is_admin ?? false, + is_active: updatedUser.is_active ?? true, + }, + }) + ); + } + + // Navigate back after successful save + navigate(-1); + } catch (err) { + const detail = + err && typeof err === 'object' && 'data' in err && typeof (err as { data: unknown }).data === 'object' + ? ((err as { data: { detail?: string } }).data?.detail ?? t('auth.profile.saveFailed')) + : t('auth.profile.saveFailed'); + setErrorMessage(detail); + } + }, + [ + displayName, + currentPassword, + newPassword, + isPasswordChangeValid, + updateCurrentUser, + currentToken, + dispatch, + navigate, + t, + ] + ); + + if (!currentUser) { + return ( +
+ +
+ ); + } + + return ( + + + {t('auth.profile.title')} + + +
+ + {/* Email (read-only) */} + + {t('auth.profile.email')} + + {t('auth.profile.emailReadOnly')} + + + {/* Display name */} + + {t('auth.profile.displayName')} + + + + + + {t('auth.profile.changePassword')} + + + {/* Current password */} + 0}> + + + + {t('auth.profile.currentPassword')} + + + + + + + + : } + variant="ghost" + size="sm" + onClick={toggleShowCurrentPassword} + tabIndex={-1} + /> + + + + + + + + {/* New password */} + 0 && !newPasswordValidation.isValid} mb={4}> + + + + {t('auth.profile.newPassword')} + + + + + + + + : } + variant="ghost" + size="sm" + onClick={toggleShowNewPassword} + tabIndex={-1} + /> + + + + {newPassword.length > 0 && !newPasswordValidation.isValid && ( + {newPasswordValidation.message} + )} + + + + + {/* Confirm new password */} + 0 && !passwordsMatch} mb={4}> + + + + {t('auth.profile.confirmPassword')} + + + + + + + + : } + variant="ghost" + size="sm" + onClick={toggleShowConfirmPassword} + tabIndex={-1} + /> + + + + {confirmPassword.length > 0 && !passwordsMatch && ( + {t('auth.profile.passwordsDoNotMatch')} + )} + + + + + {/* Generate password button – aligned with the input column */} + + + + + + + + + {errorMessage && ( + + {errorMessage} + + )} + + + + + + +
+
+ ); +}); +UserProfile.displayName = 'UserProfile'; diff --git a/invokeai/frontend/web/src/services/api/endpoints/auth.ts b/invokeai/frontend/web/src/services/api/endpoints/auth.ts index ba81c08136e..c7a8a8b1ffc 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/auth.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/auth.ts @@ -35,6 +35,32 @@ type SetupStatusResponse = { multiuser_enabled: boolean; }; +export type UserDTO = components['schemas']['UserDTO']; + +type AdminUserCreateRequest = { + email: string; + display_name?: string | null; + password: string; + is_admin?: boolean; +}; + +type AdminUserUpdateRequest = { + display_name?: string | null; + password?: string | null; + is_admin?: boolean | null; + is_active?: boolean | null; +}; + +type UserProfileUpdateRequest = { + display_name?: string | null; + current_password?: string | null; + new_password?: string | null; +}; + +type GeneratePasswordResponse = { + password: string; +}; + export const authApi = api.injectEndpoints({ endpoints: (build) => ({ login: build.mutation({ @@ -67,8 +93,61 @@ export const authApi = api.injectEndpoints({ getSetupStatus: build.query({ query: () => 'api/v1/auth/status', }), + listUsers: build.query({ + query: () => 'api/v1/auth/users', + providesTags: ['UserList'], + }), + createUser: build.mutation({ + query: (data) => ({ + url: 'api/v1/auth/users', + method: 'POST', + body: data, + }), + invalidatesTags: ['UserList'], + }), + getUser: build.query({ + query: (userId) => `api/v1/auth/users/${userId}`, + providesTags: (_result, _error, userId) => [{ type: 'UserList', id: userId }], + }), + updateUser: build.mutation({ + query: ({ userId, data }) => ({ + url: `api/v1/auth/users/${userId}`, + method: 'PATCH', + body: data, + }), + invalidatesTags: ['UserList'], + }), + deleteUser: build.mutation({ + query: (userId) => ({ + url: `api/v1/auth/users/${userId}`, + method: 'DELETE', + }), + invalidatesTags: ['UserList'], + }), + updateCurrentUser: build.mutation({ + query: (data) => ({ + url: 'api/v1/auth/me', + method: 'PATCH', + body: data, + }), + }), + generatePassword: build.query({ + query: () => 'api/v1/auth/generate-password', + }), }), }); -export const { useLoginMutation, useLogoutMutation, useGetCurrentUserQuery, useSetupMutation, useGetSetupStatusQuery } = - authApi; +export const { + useLoginMutation, + useLogoutMutation, + useGetCurrentUserQuery, + useSetupMutation, + useGetSetupStatusQuery, + useListUsersQuery, + useCreateUserMutation, + useGetUserQuery, + useUpdateUserMutation, + useDeleteUserMutation, + useUpdateCurrentUserMutation, + useLazyGeneratePasswordQuery, +} = authApi; diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts index 5b75d724e22..5be1aa2a67f 100644 --- a/invokeai/frontend/web/src/services/api/index.ts +++ b/invokeai/frontend/web/src/services/api/index.ts @@ -58,6 +58,7 @@ const tagTypes = [ // especially related to the queue and generation. 'FetchOnReconnect', 'ClientState', + 'UserList', ] as const; export type ApiTagDescription = TagDescription<(typeof tagTypes)[number]>; export const LIST_TAG = 'LIST'; diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index b605413787b..52a318f8160 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -111,7 +111,25 @@ export type paths = { delete?: never; options?: never; head?: never; - patch?: never; + /** + * Update Current User + * @description Update the current user's own profile. + * + * To change the password, both ``current_password`` and ``new_password`` must + * be provided. The current password is verified before the change is applied. + * + * Args: + * request: Profile fields to update + * current_user: The authenticated user + * + * Returns: + * The updated user + * + * Raises: + * HTTPException: 400 if current password is incorrect or new password is weak + * HTTPException: 404 if user not found + */ + patch: operations["update_current_user_api_v1_auth_me_patch"]; trace?: never; }; "/api/v1/auth/setup": { @@ -147,6 +165,126 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/auth/generate-password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Generate Password + * @description Generate a strong random password. + * + * Returns a cryptographically secure random password of 16 characters + * containing uppercase, lowercase, digits, and punctuation. + */ + get: operations["generate_password_api_v1_auth_generate_password_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Users + * @description List all users. Requires admin privileges. + * + * The internal 'system' user (created for backward compatibility) is excluded + * from the results since it cannot be managed through this interface. + * + * Returns: + * List of all real users (system user excluded) + */ + get: operations["list_users_api_v1_auth_users_get"]; + put?: never; + /** + * Create User + * @description Create a new user. Requires admin privileges. + * + * Args: + * request: New user details + * + * Returns: + * The created user + * + * Raises: + * HTTPException: 400 if email already exists or password is weak + */ + post: operations["create_user_api_v1_auth_users_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/users/{user_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get User + * @description Get a user by ID. Requires admin privileges. + * + * Args: + * user_id: The user ID + * + * Returns: + * The user + * + * Raises: + * HTTPException: 404 if user not found + */ + get: operations["get_user_api_v1_auth_users__user_id__get"]; + put?: never; + post?: never; + /** + * Delete User + * @description Delete a user. Requires admin privileges. + * + * Admins can delete any user including other admins, but cannot delete the last + * remaining admin. + * + * Args: + * user_id: The user ID + * + * Raises: + * HTTPException: 400 if attempting to delete the last admin + * HTTPException: 404 if user not found + */ + delete: operations["delete_user_api_v1_auth_users__user_id__delete"]; + options?: never; + head?: never; + /** + * Update User + * @description Update a user. Requires admin privileges. + * + * Args: + * user_id: The user ID + * request: Fields to update + * + * Returns: + * The updated user + * + * Raises: + * HTTPException: 400 if password is weak + * HTTPException: 404 if user not found + */ + patch: operations["update_user_api_v1_auth_users__user_id__patch"]; + trace?: never; + }; "/api/v1/utilities/dynamicprompts": { parameters: { query?: never; @@ -2360,6 +2498,59 @@ export type components = { */ type: "add"; }; + /** + * AdminUserCreateRequest + * @description Request body for admin to create a new user. + */ + AdminUserCreateRequest: { + /** + * Email + * @description User email address + */ + email: string; + /** + * Display Name + * @description Display name + */ + display_name?: string | null; + /** + * Password + * @description User password + */ + password: string; + /** + * Is Admin + * @description Whether user should have admin privileges + * @default false + */ + is_admin?: boolean; + }; + /** + * AdminUserUpdateRequest + * @description Request body for admin to update any user. + */ + AdminUserUpdateRequest: { + /** + * Display Name + * @description Display name + */ + display_name?: string | null; + /** + * Password + * @description New password + */ + password?: string | null; + /** + * Is Admin + * @description Whether user should have admin privileges + */ + is_admin?: boolean | null; + /** + * Is Active + * @description Whether user account should be active + */ + is_active?: boolean | null; + }; /** * Alpha Mask to Tensor * @description Convert a mask image to a tensor. Opaque regions are 1 and transparent regions are 0. @@ -10349,6 +10540,17 @@ export type components = { */ type: "freeu"; }; + /** + * GeneratePasswordResponse + * @description Response containing a generated password. + */ + GeneratePasswordResponse: { + /** + * Password + * @description Generated strong password + */ + password: string; + }; /** * Get Image Mask Bounding Box * @description Gets the bounding box of the given mask image. @@ -26672,6 +26874,27 @@ export type components = { */ last_login_at?: string | null; }; + /** + * UserProfileUpdateRequest + * @description Request body for a user to update their own profile. + */ + UserProfileUpdateRequest: { + /** + * Display Name + * @description New display name + */ + display_name?: string | null; + /** + * Current Password + * @description Current password (required when changing password) + */ + current_password?: string | null; + /** + * New Password + * @description New password + */ + new_password?: string | null; + }; /** VAEField */ VAEField: { /** @description Info to load vae submodel */ @@ -28518,6 +28741,39 @@ export interface operations { }; }; }; + update_current_user_api_v1_auth_me_patch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserProfileUpdateRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserDTO"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; setup_admin_api_v1_auth_setup_post: { parameters: { query?: never; @@ -28551,6 +28807,177 @@ export interface operations { }; }; }; + generate_password_api_v1_auth_generate_password_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GeneratePasswordResponse"]; + }; + }; + }; + }; + list_users_api_v1_auth_users_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserDTO"][]; + }; + }; + }; + }; + create_user_api_v1_auth_users_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminUserCreateRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserDTO"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_user_api_v1_auth_users__user_id__get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description User ID */ + user_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserDTO"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_user_api_v1_auth_users__user_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + /** @description User ID */ + user_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_user_api_v1_auth_users__user_id__patch: { + parameters: { + query?: never; + header?: never; + path: { + /** @description User ID */ + user_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminUserUpdateRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserDTO"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; parse_dynamicprompts: { parameters: { query?: never;