diff --git a/backend/app/keycloak_auth.py b/backend/app/keycloak_auth.py index 7139f5ce6..062424890 100644 --- a/backend/app/keycloak_auth.py +++ b/backend/app/keycloak_auth.py @@ -2,6 +2,7 @@ import json import logging from datetime import datetime +from typing import Optional from fastapi import Depends, HTTPException, Security from fastapi.security import APIKeyCookie, APIKeyHeader, OAuth2AuthorizationCodeBearer @@ -435,6 +436,43 @@ async def create_user(email: str, password: str, firstName: str, lastName: str): return user +async def update_user( + email: str, + new_email: Optional[str], + new_password: Optional[str], + new_firstName: Optional[str], + new_lastName: Optional[str], +): + """Update existing user in Keycloak.""" + keycloak_admin = KeycloakAdmin( + server_url=settings.auth_server_url, + username=settings.keycloak_username, + password=settings.keycloak_password, + realm_name=settings.keycloak_realm_name, + user_realm_name=settings.keycloak_user_realm_name, + # client_secret_key=settings.auth_client_secret, + # client_id=settings.keycloak_client_id, + verify=True, + ) + existing_user_id = keycloak_admin.get_user_id(email) + existing_user = keycloak_admin.get_user(existing_user_id) + # Update user and set password + keycloak_admin.update_user( + existing_user_id, + { + "email": new_email or existing_user["email"], + "username": new_email or existing_user["email"], + "firstName": new_firstName or existing_user["firstName"], + "lastName": new_lastName or existing_user["lastName"], + }, + ) + if new_password: + keycloak_admin.set_user_password(existing_user_id, new_password, False) + + updated_user = keycloak_admin.get_user(existing_user_id) + return updated_user + + def delete_user(email: str): """Create a user in Keycloak.""" keycloak_admin = KeycloakAdmin( diff --git a/backend/app/models/users.py b/backend/app/models/users.py index e7055dbcf..52d1783a0 100644 --- a/backend/app/models/users.py +++ b/backend/app/models/users.py @@ -18,6 +18,12 @@ class UserIn(UserBase): password: str +class UserUpdate(BaseModel): + first_name: Optional[str] + last_name: Optional[str] + password: Optional[str] + + class UserLogin(BaseModel): email: EmailStr password: str diff --git a/backend/app/routers/authentication.py b/backend/app/routers/authentication.py index ced73e5ae..7da6866b9 100644 --- a/backend/app/routers/authentication.py +++ b/backend/app/routers/authentication.py @@ -5,9 +5,10 @@ enable_disable_user, get_current_user, keycloak_openid, + update_user, ) from app.models.datasets import DatasetDBViewList -from app.models.users import UserDB, UserIn, UserLogin, UserOut +from app.models.users import UserDB, UserIn, UserLogin, UserOut, UserUpdate from app.routers.utils import save_refresh_token from beanie import PydanticObjectId from fastapi import APIRouter, Depends, HTTPException @@ -15,6 +16,7 @@ KeycloakAuthenticationError, KeycloakGetError, KeycloakPostError, + KeycloakPutError, ) from passlib.hash import bcrypt @@ -97,6 +99,45 @@ async def authenticate_user(email: str, password: str): return user +@router.patch("/users/me", response_model=UserOut) +async def update_current_user( + userUpdate: UserUpdate, current_user=Depends(get_current_user) +): + try: + await update_user( + current_user.email, + None, + userUpdate.password, + userUpdate.first_name, + userUpdate.last_name, + ) + except KeycloakGetError as e: + raise HTTPException( + status_code=e.response_code, + detail=json.loads(e.error_message), + headers={"WWW-Authenticate": "Bearer"}, + ) + except KeycloakPutError as e: + raise HTTPException( + status_code=e.response_code, + detail=json.loads(e.error_message), + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Update local user + user = await UserDB.find_one(UserDB.email == current_user.email) + + if userUpdate.first_name: + user.first_name = userUpdate.first_name + if userUpdate.last_name: + user.last_name = userUpdate.last_name + if userUpdate.password: + user.hashed_password = bcrypt.hash(userUpdate.password) + + await user.save() + return user.dict() + + @router.get("/users/me/is_admin", response_model=bool) async def get_admin( dataset_id: str = None, current_username=Depends(get_current_user) diff --git a/frontend/src/openapi/v2/index.ts b/frontend/src/openapi/v2/index.ts index 7644abf9d..495cccc76 100644 --- a/frontend/src/openapi/v2/index.ts +++ b/frontend/src/openapi/v2/index.ts @@ -78,6 +78,7 @@ export type { UserAPIKeyOut } from './models/UserAPIKeyOut'; export type { UserIn } from './models/UserIn'; export type { UserLogin } from './models/UserLogin'; export type { UserOut } from './models/UserOut'; +export type { UserUpdate } from './models/UserUpdate'; export type { ValidationError } from './models/ValidationError'; export type { VisualizationConfigIn } from './models/VisualizationConfigIn'; export type { VisualizationConfigOut } from './models/VisualizationConfigOut'; diff --git a/frontend/src/openapi/v2/models/UserUpdate.ts b/frontend/src/openapi/v2/models/UserUpdate.ts new file mode 100644 index 000000000..ec7bb5422 --- /dev/null +++ b/frontend/src/openapi/v2/models/UserUpdate.ts @@ -0,0 +1,9 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type UserUpdate = { + first_name?: string; + last_name?: string; + password?: string; +} diff --git a/frontend/src/openapi/v2/services/LoginService.ts b/frontend/src/openapi/v2/services/LoginService.ts index 4be660baa..dfee3ec59 100644 --- a/frontend/src/openapi/v2/services/LoginService.ts +++ b/frontend/src/openapi/v2/services/LoginService.ts @@ -4,6 +4,7 @@ import type { UserIn } from '../models/UserIn'; import type { UserLogin } from '../models/UserLogin'; import type { UserOut } from '../models/UserOut'; +import type { UserUpdate } from '../models/UserUpdate'; import type { CancelablePromise } from '../core/CancelablePromise'; import { request as __request } from '../core/request'; @@ -49,6 +50,26 @@ export class LoginService { }); } + /** + * Update Current User + * @param requestBody + * @returns UserOut Successful Response + * @throws ApiError + */ + public static updateCurrentUserApiV2UsersMePatch( + requestBody: UserUpdate, + ): CancelablePromise { + return __request({ + method: 'PATCH', + path: `/api/v2/users/me`, + body: requestBody, + mediaType: 'application/json', + errors: { + 422: `Validation Error`, + }, + }); + } + /** * Get Admin * @param datasetId diff --git a/openapi.json b/openapi.json index c41b44207..902ccca97 100644 --- a/openapi.json +++ b/openapi.json @@ -156,6 +156,58 @@ } } }, + "/api/v2/users/me": { + "patch": { + "tags": [ + "login" + ], + "summary": "Update Current User", + "operationId": "update_current_user_api_v2_users_me_patch", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserOut" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "OAuth2AuthorizationCodeBearer": [] + }, + { + "APIKeyHeader": [] + }, + { + "APIKeyCookie": [] + } + ] + } + }, "/api/v2/users/me/is_admin": { "get": { "tags": [ @@ -14736,6 +14788,24 @@ }, "description": "Document Mapping class.\n\nFields:\n\n- `id` - MongoDB document ObjectID \"_id\" field.\nMapped to the PydanticObjectId class\n\nInherited from:\n\n- Pydantic BaseModel\n- [UpdateMethods](https://roman-right.github.io/beanie/api/interfaces/#aggregatemethods)" }, + "UserUpdate": { + "title": "UserUpdate", + "type": "object", + "properties": { + "first_name": { + "title": "First Name", + "type": "string" + }, + "last_name": { + "title": "Last Name", + "type": "string" + }, + "password": { + "title": "Password", + "type": "string" + } + } + }, "ValidationError": { "title": "ValidationError", "required": [