diff --git a/compendium_v2/routes/api.py b/compendium_v2/routes/api.py index 76ed727e5aa9a3162e9a51cc2ee4dd196dde345b..85681a24d6c79512eae0cd3be60017138223cee0 100644 --- a/compendium_v2/routes/api.py +++ b/compendium_v2/routes/api.py @@ -12,6 +12,7 @@ from compendium_v2.routes.ec_projects import routes as ec_routes from compendium_v2.routes.policy import routes as policy from compendium_v2.routes.survey import routes as survey from compendium_v2.routes.user import routes as user_routes +from compendium_v2.routes.nren import routes as nren_routes routes = Blueprint('compendium-v2-api', __name__) routes.register_blueprint(budget_routes, url_prefix='/budget') @@ -23,7 +24,7 @@ routes.register_blueprint(ec_routes, url_prefix='/ec-project') routes.register_blueprint(policy, url_prefix='/policy') routes.register_blueprint(survey, url_prefix='/survey') routes.register_blueprint(user_routes, url_prefix='/user') - +routes.register_blueprint(nren_routes, url_prefix='/nren') logger = logging.getLogger(__name__) diff --git a/compendium_v2/routes/nren.py b/compendium_v2/routes/nren.py new file mode 100644 index 0000000000000000000000000000000000000000..dc1c075c53913f2782b51b910edd14c73244f558 --- /dev/null +++ b/compendium_v2/routes/nren.py @@ -0,0 +1,46 @@ +import logging +from typing import Any + +from flask import Blueprint, jsonify + +from compendium_v2.routes import common +from compendium_v2.db.auth_model import NREN +from sqlalchemy import select + +from compendium_v2.db import db + +routes = Blueprint('nren', __name__) +logger = logging.getLogger(__name__) + +NREN_RESPONSE_SCHEMA = { + '$schema': 'http://json-schema.org/draft-07/schema#', + + 'definitions': { + 'nren': { + 'type': 'object', + 'properties': { + 'id': {'type': 'integer'}, + 'name': {'type': 'string'}, + 'country': {'type': 'string'}, + }, + 'additionalProperties': False + } + }, + + 'type': 'array', + 'items': {'$ref': '#/definitions/nren'} +} + + +@routes.route('/list', methods=['GET']) +@common.require_accepts_json +def all_nrens_view() -> Any: + def _extract_nren_data(entry: NREN): + return { + 'id': entry.id, + 'name': entry.name, + 'country': entry.country + } + + nren_entries = [_extract_nren_data(nren) for nren in db.session.scalars(select(NREN))] + return jsonify(nren_entries) diff --git a/compendium_v2/routes/user.py b/compendium_v2/routes/user.py index 96dca333afa6fb0b873a1b4a4ca4fabf51dd5c38..d3326ec2ef2181c9d7cd0576f8be2265aeb73171 100644 --- a/compendium_v2/routes/user.py +++ b/compendium_v2/routes/user.py @@ -1,15 +1,16 @@ import logging from typing import Any, Union -from flask import Blueprint, jsonify -from flask_login import current_user, AnonymousUserMixin # type: ignore +from flask import Blueprint, jsonify, request +from flask_login import current_user, AnonymousUserMixin, login_required # type: ignore from sqlalchemy import select +from compendium_v2.auth.session_management import admin_required from compendium_v2.db import db from compendium_v2.db.auth_model import User, ROLES +from compendium_v2.db.model import NREN from compendium_v2.routes import common - routes = Blueprint('user', __name__) logger = logging.getLogger(__name__) @@ -29,11 +30,18 @@ USER_RESPONSE_SCHEMA = { 'user': { 'type': 'object', 'properties': { - 'name': {'type': 'string'}, + 'name': {'type': ['string', 'null']}, 'email': {'type': ['string', 'null']}, 'permissions': {'$ref': '#/definitions/permissions'}, + 'id': {'type': 'uuid'}, + 'role': {'type': 'string'}, + 'oidc_sub': {'type': 'string'}, + 'nrens': { + 'type': 'array', + 'items': {'type': 'string'} + } }, - 'required': ['name', 'email', 'permissions'], + 'required': ['permissions'], 'additionalProperties': False } }, @@ -43,6 +51,28 @@ USER_RESPONSE_SCHEMA = { } +def _extract_user(user: Union[User, AnonymousUserMixin]): + if isinstance(user, AnonymousUserMixin): + return { + 'permissions': { + 'admin': False, + 'active': False, + } + } + return { + 'name': user.fullname, + 'email': user.email, + 'permissions': { + 'admin': user.roles == ROLES.admin, + 'active': user.active, + }, + 'id': user.id, + 'role': user.roles.value, + 'oidc_sub': user.oidc_sub, + 'nrens': [nren.name for nren in user.nrens] + } + + @routes.route('/', methods=['GET']) @common.require_accepts_json def current_user_view() -> Any: @@ -57,42 +87,84 @@ def current_user_view() -> Any: :return: """ - def _extract_data(entry: Union[User, AnonymousUserMixin]): - if isinstance(entry, AnonymousUserMixin): - return { - 'name': '', - 'email': None, - 'permissions': { - 'admin': False, - 'active': False, - } - } - return { - 'name': entry.fullname, - 'email': entry.email, - 'permissions': { - 'admin': entry.roles == ROLES.admin, - 'active': entry.active, - } - } - - return jsonify(_extract_data(current_user)) + return jsonify(_extract_user(current_user)) @routes.route('/list', methods=['GET']) @common.require_accepts_json +@login_required +@admin_required def all_users_view() -> Any: # TODO schema and docstring - def _extract_data(entry: User): - return { - 'id': entry.id, - 'email': entry.email, - 'roles': entry.roles.value, - 'active': entry.active, - 'nrens': [nren.name for nren in entry.nrens] - } - - entries = [_extract_data(entry) for entry in db.session.scalars( + entries = [_extract_user(user) for user in db.session.scalars( select(User).order_by(User.email)).unique()] return jsonify(entries) + + +@routes.route('/', methods=['PUT']) +@common.require_accepts_json +@login_required +@admin_required +def update_user_view() -> Any: + """ + Handler for updating user information via PUT request. + + Request data should be in JSON format with the fields you want to update. + The response will be formatted the same way as in the current_user_view. + + Example JSON request data: + { + "name": "Updated Name", + "email": "updated@example.com", + "roles": ["role1", "role2"], + "active": False + } + + :return: + """ + + def _update_user_data(user: User, update_data: dict): + + new_roles = update_data.get('roles', user.roles.value) + + if new_roles != user.roles.value: + if user == current_user: + return jsonify({'success': False, 'message': 'Cannot change your own role.'}), 400 + + user.roles = new_roles + + if 'active' in update_data: + _active = bool(update_data['active']) + if _active != user.active: + if user == current_user: + return jsonify({'success': False, 'message': 'Cannot deactivate yourself.'}), 400 + + user.active = _active + + nrens = update_data.get('nrens', None) + if nrens is not None: + new_nrens = None + try: + new_nrens = db.session.scalars(select(NREN).filter(NREN.id.in_(update_data['nrens']))).all() + user.nrens = [nren for nren in new_nrens] + db.session.commit() + except Exception: + return jsonify({'success': False, 'message': 'No valid NREN IDs provided.'}), 400 + + return jsonify({'success': True, 'message': 'User updated successfully'}) + + body = request.get_json() + if not body: + return jsonify({"success": False, 'message': 'Invalid request'}), 400 + + user_id = body.get("id") + if not user_id: + return jsonify({"success": False, 'message': 'No user ID provided in the request data.'}), 400 + + user = db.session.execute(select(User).filter_by(id=user_id)).scalar() + + if not user: + return jsonify({"success": False, 'message': 'User not found.'}), 404 + + return _update_user_data(user, body) diff --git a/survey-frontend/src/App.tsx b/survey-frontend/src/App.tsx index df23656751291019c9f6a6894065037af560d1c6..f26d94d106bc2a37dc408a15c7fbf3515804bf72 100644 --- a/survey-frontend/src/App.tsx +++ b/survey-frontend/src/App.tsx @@ -6,7 +6,7 @@ import SurveyManagementComponent from './SurveyManagementComponent'; import UserManagementComponent from './UserManagementComponent'; import SurveyContainerComponent from "./SurveyContainerComponent"; import ExternalPageNavBar from "shared/ExternalPageNavBar" -import UserProvider from "shared/UserProvider"; +import UserProvider from "./providers/UserProvider"; function App(): ReactElement { diff --git a/survey-frontend/src/Schema.tsx b/survey-frontend/src/Schema.tsx index 071dc924340ea3ae061d47e14ac6b3d5d9f7184b..b3bc786d5df529e45b4d5345289c3aa6c85ba164 100644 --- a/survey-frontend/src/Schema.tsx +++ b/survey-frontend/src/Schema.tsx @@ -1,3 +1,22 @@ +export interface User { + id: string, + email: string, + role: string, + name: string, + oidc_sub: string, + nrens: string[], + permissions: { + admin: boolean, + active: boolean, + }, + editable: boolean, +} + +export interface Nren { + id: string, + name: string, + country: string +} export enum VerificationStatus { New = "new", // a question that was not answered last year diff --git a/survey-frontend/src/UserManagementComponent.tsx b/survey-frontend/src/UserManagementComponent.tsx index 5c0235b874d6f02370d524daceacba0c901bd287..78d8b5dd9ca1bbc0d22c070bc510977e8dbe903f 100644 --- a/survey-frontend/src/UserManagementComponent.tsx +++ b/survey-frontend/src/UserManagementComponent.tsx @@ -1,4 +1,7 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useContext } from "react"; +import { Button, Table } from "react-bootstrap"; +import { userContext } from "./providers/UserProvider"; +import { User, Nren } from "./Schema"; async function fetchUsers(): Promise<User[]> { @@ -12,33 +15,188 @@ async function fetchUsers(): Promise<User[]> { } } -interface User { - id: string, - email: string, - roles: string, - active: boolean, - nrens: string[] +async function fetchNrens(): Promise<Nren[]> { + try { + const response = await fetch('/api/nren/list'); + const userList = await response.json(); + return userList + } catch (error) { + console.log('handle this better..'); + return []; + } } +const saveUser = (user) => { + const requestOptions = { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(user), + }; + + fetch('/api/user', requestOptions) + .then((response) => { + return response.json(); + }) + .catch((error) => { + const response = error.response; + response.json().then((json) => { + console.log(json); + }); + }); +}; + + function UserManagementComponent() { const [users, setUsers] = useState<User[]>([]); + const [nrens, setNrens] = useState<Nren[]>([]); + const { user: loggedInUser } = useContext(userContext); + console.log(loggedInUser) useEffect(() => { // Fetch user fetchUsers().then((userList) => { setUsers(userList); }); + + fetchNrens().then((nrenList) => { + setNrens(nrenList.sort((a, b) => a.name.localeCompare(b.name))) + }) }, []); + const handleEdit = (user: User) => { + const index = users.findIndex((u) => u.id === user.id); + const updatedUsers = [...users]; + updatedUsers[index].editable = true; + setUsers(updatedUsers); + }; + + const handleSave = (user: User) => { + const index = users.findIndex((u) => u.id === user.id); + const updatedUsers = [...users]; + updatedUsers[index].editable = false; + setUsers(updatedUsers); + + // Persist the changes to the server + saveUser(user); + }; + + const handleInputChange = (event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>, user: User) => { + const index = users.findIndex((u) => u.id === user.id); + const updatedUsers = [...users]; + + const { name, type } = event.target; + if (type === 'checkbox') { + user[name] = (event.target as HTMLInputElement).checked ? true : false; + } else { + user[name] = (event.target as HTMLInputElement).value; + } + + if (event.target.name === 'nrens') { + updatedUsers[index].nrens = [event.target.value]; + console.log("updated nren") + } + setUsers(updatedUsers); + }; + + const findNren = (value) => { + return nrens.find((nren) => { + return nren.id == value || nren.name == value + })?.name + } + return ( <div> - <table> - {users.map(user => ( - <tr key={user.id}> - {user.id} - {user.email} - {user.active ? 'active' : 'inactive'} - {user.roles} - {user.nrens.join()} - </tr> - ))} - </table> + <h1> User Management Page</h1> + <Table> + <thead> + <tr> + <th className='pt-3' style={{ border: "1px solid #ddd" }}> Id </th> + <th className='pt-3' style={{ border: "1px solid #ddd" }}> Active </th> + <th className='pt-3' style={{ border: "1px solid #ddd" }}> Roles </th> + <th className='pt-3' style={{ border: "1px solid #ddd" }}> Email</th> + <th className='pt-3' style={{ border: "1px solid #ddd" }}> Full Name</th> + <th className='pt-3' style={{ border: "1px solid #ddd" }}> OIDC Sub</th> + <th className='pt-3' style={{ border: "1px solid #ddd" }}> NREN</th> + <th className="pt-3" style={{ border: "1px solid #ddd" }}>Actions</th> + </tr> + </thead> + <tbody> + {users.map(user => ( + <tr key={user.id}> + <td style={{ border: "1px dotted #ddd" }}>{user.id}</td> + <td style={{ border: "1px dotted #ddd" }}> + {user.editable ? ( + <input + type="checkbox" + name="active" + checked={user.permissions.active} + onChange={(event) => handleInputChange(event, user)} + /> + ) : ((user.permissions.active ? 'Active' : 'Inactive'))} + </td> + <td style={{ border: "1px dotted #ddd" }}> + {user.editable ? ( + <select + name="roles" + value={user.role} + onChange={(event) => handleInputChange(event, user)}> + <option value="admin">Admin</option> + <option value="user">User</option> + </select> + ) : ( + user.role + )} + </td> + + <td style={{ border: "1px dotted #ddd" }}> + {(user.email)} + </td> + <td style={{ border: "1px dotted #ddd" }}> + {(user.name)} + </td> + <td style={{ border: "1px dotted #ddd" }}> + {(user.oidc_sub)} + </td> + <td style={{ border: '1px dotted #ddd' }}> + {user.editable ? ( + nrens.length > 0 ? ( + <select + name="nrens" + multiple={false} + defaultValue={user.nrens.length > 0 ? nrens.find((nren) => { + return nren.name == user.nrens[0] || nren.id == user.nrens[0] + })?.id : undefined} + onChange={(event) => handleInputChange(event, user)}> + <option> + Select NREN + </option> + {nrens.map((nren) => ( + <option key={nren.id} value={nren.id}> + {nren.name} + </option> + ))} + </select> + ) : ( + <div>No options available</div> + ) + ) : ( + (user.nrens?.length || 0) > 0 ? findNren(user.nrens[0]) : "NREN not selected" + )} + </td> + <td style={{ border: "1px dotted #ddd" }}> + {loggedInUser?.role == 'admin' && user.editable ? ( + <Button onClick={() => handleSave(user)}>Save</Button> + ) : loggedInUser?.role == 'admin' && !user.editable ? ( + <Button onClick={() => handleEdit(user)}>Edit</Button> + ) : null} + </td> + </tr> + ))} + </tbody> + + </Table> </div> ); diff --git a/survey-frontend/src/providers/UserProvider.tsx b/survey-frontend/src/providers/UserProvider.tsx new file mode 100644 index 0000000000000000000000000000000000000000..44bc874497441c86d5cf2006f73bfab76f3ae62c --- /dev/null +++ b/survey-frontend/src/providers/UserProvider.tsx @@ -0,0 +1,48 @@ +import React, { createContext, useState, useEffect } from 'react'; +import { User } from '../Schema'; + +interface Props { + children: React.ReactNode; +} + + +async function fetchUser(): Promise<User> { + const response = await fetch('/api/user'); + const user = await response.json(); + return user +} + +const anonymousUser: User = { 'name': '', email: '', permissions: { admin: false, active: false }, editable: false, id: '', nrens: [], oidc_sub: '', role: '' }; + +const userContext = createContext<{ + user: User; + logout: () => void; +}>({ + user: anonymousUser, + logout: () => { } +}); + + +const UserProvider: React.FC<Props> = ({ children }) => { + const [user, setUser] = useState<User>(anonymousUser); + + async function logoutUser() { + await fetch('/logout'); + setUser(anonymousUser); + } + + useEffect(() => { + fetchUser().then(user => { + setUser(user) + }); + }, []); + + return ( + <userContext.Provider value={{ user, logout: logoutUser }}> + {children} + </userContext.Provider> + ); +}; + +export { userContext }; +export default UserProvider; \ No newline at end of file