From 689625b0d55ea2f2c1b49f1ba9c8abc01ae1182b Mon Sep 17 00:00:00 2001 From: swve Date: Mon, 22 Jan 2024 20:37:11 +0100 Subject: [PATCH] feat: users management --- apps/api/src/db/organizations.py | 9 + apps/api/src/db/users.py | 4 +- apps/api/src/routers/orgs.py | 50 +++++ apps/api/src/security/rbac/rbac.py | 30 ++- apps/api/src/services/courses/chapters.py | 2 +- apps/api/src/services/courses/courses.py | 2 +- apps/api/src/services/orgs/orgs.py | 210 +++++++++++++++++- apps/api/src/services/users/avatars.py | 2 +- apps/web/app/orgs/[orgslug]/dash/page.tsx | 37 ++- .../settings/[subpage]/page.tsx | 11 +- .../dash/users/settings/[subpage]/page.tsx | 63 ++++++ .../EditCourseGeneral/ThumbnailUpdate.tsx | 2 +- .../components/Dashboard/UI/BreadCrumbs.tsx | 2 +- apps/web/components/Dashboard/UI/LeftMenu.tsx | 7 +- .../UserEditGeneral/UserEditGeneral.tsx | 0 .../UserEditPassword/UserEditPassword.tsx | 0 .../Dashboard/Users/OrgUsers/OrgUsers.tsx | 116 ++++++++++ .../Modals/Course/Create/CreateCourse.tsx | 2 +- .../Modals/Dash/OrgUsers/RolesUpdate.tsx | 82 +++++++ .../components/StyledElements/Form/Form.tsx | 2 +- apps/web/services/organizations/orgs.ts | 14 +- apps/web/services/utils/ts/requests.ts | 10 +- 22 files changed, 621 insertions(+), 36 deletions(-) rename apps/web/app/orgs/[orgslug]/dash/{user => user-account}/settings/[subpage]/page.tsx (87%) create mode 100644 apps/web/app/orgs/[orgslug]/dash/users/settings/[subpage]/page.tsx rename apps/web/components/Dashboard/{User => UserAccount}/UserEditGeneral/UserEditGeneral.tsx (100%) rename apps/web/components/Dashboard/{User => UserAccount}/UserEditPassword/UserEditPassword.tsx (100%) create mode 100644 apps/web/components/Dashboard/Users/OrgUsers/OrgUsers.tsx create mode 100644 apps/web/components/Objects/Modals/Dash/OrgUsers/RolesUpdate.tsx diff --git a/apps/api/src/db/organizations.py b/apps/api/src/db/organizations.py index f9257be6..427c57d1 100644 --- a/apps/api/src/db/organizations.py +++ b/apps/api/src/db/organizations.py @@ -1,5 +1,8 @@ from typing import Optional +from pydantic import BaseModel from sqlmodel import Field, SQLModel +from src.db.roles import RoleRead + from src.db.organization_config import OrganizationConfig @@ -32,3 +35,9 @@ class OrganizationRead(OrganizationBase): config: Optional[OrganizationConfig | dict] creation_date: str update_date: str + + +class OrganizationUser(BaseModel): + from src.db.users import UserRead + user: UserRead + role: RoleRead diff --git a/apps/api/src/db/users.py b/apps/api/src/db/users.py index 6e1831a6..8d98c3e7 100644 --- a/apps/api/src/db/users.py +++ b/apps/api/src/db/users.py @@ -1,9 +1,8 @@ from typing import Optional from pydantic import BaseModel, EmailStr from sqlmodel import Field, SQLModel - from src.db.roles import RoleRead -from src.db.organizations import OrganizationRead + class UserBase(SQLModel): @@ -45,6 +44,7 @@ class PublicUser(UserRead): class UserRoleWithOrg(BaseModel): + from src.db.organizations import OrganizationRead role: RoleRead org: OrganizationRead diff --git a/apps/api/src/routers/orgs.py b/apps/api/src/routers/orgs.py index 9870b3db..51d48f9d 100644 --- a/apps/api/src/routers/orgs.py +++ b/apps/api/src/routers/orgs.py @@ -8,6 +8,7 @@ from src.db.organizations import ( OrganizationCreate, OrganizationRead, OrganizationUpdate, + OrganizationUser, ) from src.core.events.database import get_db_session from src.security.auth import get_current_user @@ -17,9 +18,12 @@ from src.services.orgs.orgs import ( delete_org, get_organization, get_organization_by_slug, + get_organization_users, get_orgs_by_user, + remove_user_from_org, update_org, update_org_logo, + update_user_role, ) @@ -69,6 +73,52 @@ async def api_get_org( return await get_organization(request, org_id, db_session, current_user) +@router.get("/{org_id}/users") +async def api_get_org_users( + request: Request, + org_id: str, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +) -> list[OrganizationUser]: + """ + Get single Org by ID + """ + return await get_organization_users(request, org_id, db_session, current_user) + + +@router.put("/{org_id}/users/{user_id}/role/{role_uuid}") +async def api_update_user_role( + request: Request, + org_id: str, + user_id: str, + role_uuid: str, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + """ + Update user role + """ + return await update_user_role( + request, org_id, user_id, role_uuid, db_session, current_user + ) + + +@router.delete("/{org_id}/users/{user_id}") +async def api_remove_user_from_org( + request: Request, + org_id: int, + user_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + """ + Remove user from org + """ + return await remove_user_from_org( + request, org_id, user_id, db_session, current_user + ) + + @router.get("/slug/{org_slug}") async def api_get_org_by_slug( request: Request, diff --git a/apps/api/src/security/rbac/rbac.py b/apps/api/src/security/rbac/rbac.py index 34098744..150a8f7e 100644 --- a/apps/api/src/security/rbac/rbac.py +++ b/apps/api/src/security/rbac/rbac.py @@ -16,7 +16,7 @@ async def authorization_verify_if_element_is_public( element_uuid: str, action: Literal["read"], db_session: Session, -): +): element_nature = await check_element_type(element_uuid) # Verifies if the element is public if element_nature == ("courses" or "collections") and action == "read": @@ -106,6 +106,34 @@ async def authorization_verify_based_on_roles( return False +async def authorization_verify_based_on_org_admin_status( + request: Request, + user_id: int, + action: Literal["read", "update", "delete", "create"], + element_uuid: str, + db_session: Session, +): + await check_element_type(element_uuid) + + # Get user roles bound to an organization and standard roles + statement = ( + select(Role) + .join(UserOrganization) + .where((UserOrganization.org_id == Role.org_id) | (Role.org_id == null())) + .where(UserOrganization.user_id == user_id) + ) + + user_roles_in_organization_and_standard_roles = db_session.exec(statement).all() + + # Find in roles list if there is a role that matches users action for this type of element + for role in user_roles_in_organization_and_standard_roles: + role = Role.from_orm(role) + if role.id == 1 or role.id == 2: + return True + else: + return False + + # Tested and working async def authorization_verify_based_on_roles_and_authorship( request: Request, diff --git a/apps/api/src/services/courses/chapters.py b/apps/api/src/services/courses/chapters.py index 5d5b78ca..6879250f 100644 --- a/apps/api/src/services/courses/chapters.py +++ b/apps/api/src/services/courses/chapters.py @@ -225,7 +225,7 @@ async def get_course_chapters( chapters = [ChapterRead(**chapter.dict(), activities=[]) for chapter in chapters] # RBAC check - await rbac_check(request, course.course_uuid, current_user, "read", db_session) + await rbac_check(request, course.course_uuid, current_user, "read", db_session) # type: ignore # Get activities for each chapter for chapter in chapters: diff --git a/apps/api/src/services/courses/courses.py b/apps/api/src/services/courses/courses.py index a420d0b0..cc007a56 100644 --- a/apps/api/src/services/courses/courses.py +++ b/apps/api/src/services/courses/courses.py @@ -213,7 +213,7 @@ async def update_course_thumbnail( if thumbnail_file and thumbnail_file.filename: name_in_disk = f"{course_uuid}_thumbnail_{uuid4()}.{thumbnail_file.filename.split('.')[-1]}" await upload_thumbnail( - thumbnail_file, name_in_disk, 'users', course.course_uuid + thumbnail_file, name_in_disk, org.org_uuid, course.course_uuid ) # Update course diff --git a/apps/api/src/services/orgs/orgs.py b/apps/api/src/services/orgs/orgs.py index 3ef6b304..4efb8277 100644 --- a/apps/api/src/services/orgs/orgs.py +++ b/apps/api/src/services/orgs/orgs.py @@ -4,6 +4,7 @@ from datetime import datetime from typing import Literal from uuid import uuid4 from sqlmodel import Session, select +from src.db.roles import Role, RoleRead from src.db.organization_config import ( AIConfig, AIEnabledFeatures, @@ -15,16 +16,18 @@ from src.db.organization_config import ( OrganizationConfigBase, ) from src.security.rbac.rbac import ( - authorization_verify_based_on_roles_and_authorship, + authorization_verify_based_on_org_admin_status, + authorization_verify_based_on_roles, authorization_verify_if_user_is_anon, ) -from src.db.users import AnonymousUser, PublicUser +from src.db.users import AnonymousUser, PublicUser, User, UserRead from src.db.user_organizations import UserOrganization from src.db.organizations import ( Organization, OrganizationCreate, OrganizationRead, OrganizationUpdate, + OrganizationUser, ) from src.services.orgs.logos import upload_org_logo from fastapi import HTTPException, UploadFile, status, Request @@ -66,6 +69,199 @@ async def get_organization( return org +async def get_organization_users( + request: Request, + org_id: str, + db_session: Session, + current_user: PublicUser | AnonymousUser, +) -> list[OrganizationUser]: + statement = select(Organization).where(Organization.id == org_id) + result = db_session.exec(statement) + + org = result.first() + + if not org: + raise HTTPException( + status_code=404, + detail="Organization not found", + ) + + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "read", db_session) + + statement = ( + select(User) + .join(UserOrganization) + .join(Organization) + .where(Organization.id == org_id) + ) + users = db_session.exec(statement) + users = users.all() + + org_users_list = [] + + for user in users: + statement = select(UserOrganization).where( + UserOrganization.user_id == user.id, UserOrganization.org_id == org_id + ) + result = db_session.exec(statement) + user_org = result.first() + + if not user_org: + logging.error(f"User {user.id} not found") + + # skip this user + continue + + + statement = select(Role).where(Role.id == user_org.role_id) + result = db_session.exec(statement) + + role = result.first() + + if not role: + logging.error(f"Role {user_org.role_id} not found") + + # skip this user + continue + + statement = select(User).where(User.id == user_org.user_id) + result = db_session.exec(statement) + + user = result.first() + + if not user: + logging.error(f"User {user_org.user_id} not found") + + # skip this user + continue + + user = UserRead.from_orm(user) + role = RoleRead.from_orm(role) + + org_user = OrganizationUser( + user=user, + role=role, + ) + + org_users_list.append(org_user) + + return org_users_list + + +async def remove_user_from_org( + request: Request, + org_id: int, + user_id: int, + db_session: Session, + current_user: PublicUser | AnonymousUser, +): + statement = select(Organization).where(Organization.id == org_id) + result = db_session.exec(statement) + + org = result.first() + + if not org: + raise HTTPException( + status_code=404, + detail="Organization not found", + ) + + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "read", db_session) + + statement = select(UserOrganization).where( + UserOrganization.user_id == user_id, UserOrganization.org_id == org.id + ) + result = db_session.exec(statement) + + user_org = result.first() + + if not user_org: + raise HTTPException( + status_code=404, + detail="User not found", + ) + + # Check if user is the last admin + statement = select(UserOrganization).where( + UserOrganization.org_id == org.id, UserOrganization.role_id == 1 + ) + result = db_session.exec(statement) + admins = result.all() + + if len(admins) == 1 and admins[0].user_id == user_id: + raise HTTPException( + status_code=400, + detail="You can't remove the last admin of the organization", + ) + + + db_session.delete(user_org) + db_session.commit() + + return {"detail": "User removed from org"} + + +async def update_user_role( + request: Request, + org_id: str, + user_id: str, + role_uuid: str, + db_session: Session, + current_user: PublicUser | AnonymousUser, +): + # find role + statement = select(Role).where(Role.role_uuid == role_uuid) + result = db_session.exec(statement) + + role = result.first() + + if not role: + raise HTTPException( + status_code=404, + detail="Role not found", + ) + + role_id = role.id + + statement = select(Organization).where(Organization.id == org_id) + result = db_session.exec(statement) + + org = result.first() + + if not org: + raise HTTPException( + status_code=404, + detail="Organization not found", + ) + + # RBAC check + await rbac_check(request, org.org_uuid, current_user, "read", db_session) + + statement = select(UserOrganization).where( + UserOrganization.user_id == user_id, UserOrganization.org_id == org.id + ) + result = db_session.exec(statement) + + user_org = result.first() + + if not user_org: + raise HTTPException( + status_code=404, + detail="User not found", + ) + + if role_id is not None: + user_org.role_id = role_id + + db_session.add(user_org) + db_session.commit() + db_session.refresh(user_org) + + return {"detail": "User role updated"} + + async def get_organization_by_slug( request: Request, org_slug: str, @@ -443,7 +639,7 @@ async def get_orgs_by_user( async def rbac_check( request: Request, - org_id: str, + org_uuid: str, current_user: PublicUser | AnonymousUser, action: Literal["create", "read", "update", "delete"], db_session: Session, @@ -455,8 +651,12 @@ async def rbac_check( else: await authorization_verify_if_user_is_anon(current_user.id) - await authorization_verify_based_on_roles_and_authorship( - request, current_user.id, action, org_id, db_session + await authorization_verify_based_on_roles( + request, current_user.id, action, org_uuid, db_session + ) + + await authorization_verify_based_on_org_admin_status( + request, current_user.id, action, org_uuid, db_session ) diff --git a/apps/api/src/services/users/avatars.py b/apps/api/src/services/users/avatars.py index 5fe24528..53a841ba 100644 --- a/apps/api/src/services/users/avatars.py +++ b/apps/api/src/services/users/avatars.py @@ -5,7 +5,7 @@ async def upload_avatar(avatar_file, name_in_disk, user_uuid): contents = avatar_file.file.read() try: await upload_content( - f"avatars", + "avatars", "users", user_uuid, contents, diff --git a/apps/web/app/orgs/[orgslug]/dash/page.tsx b/apps/web/app/orgs/[orgslug]/dash/page.tsx index 55af25aa..6dc6ac7f 100644 --- a/apps/web/app/orgs/[orgslug]/dash/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/page.tsx @@ -2,7 +2,7 @@ import Image from 'next/image' import React from 'react' import learnhousetextlogo from '../../../../public/learnhouse_logo.png' import learnhouseiconlogo from '../../../../public/learnhouse_bigicon.png' -import { BookCopy, School, Settings } from 'lucide-react' +import { BookCopy, School, Settings, Users } from 'lucide-react' import Link from 'next/link' function DashboardHome() { @@ -12,33 +12,46 @@ function DashboardHome() { learnhouse logo
- +
Courses

Create and manage courses, chapters and ativities

- +
Organization

Configure your Organization general settings

- +
- -
Personal Settings
-

Configure your personal settings, passwords, email

+ +
Users
+

Manage your Organization's users, roles

+
+ + +
+
+
+
+ + +
Learn LearnHouse
+ +
+
+ +
+ +
Account Settings
+

Configure your personal settings, passwords, email

-
- - -
Learn LearnHouse
- ) } diff --git a/apps/web/app/orgs/[orgslug]/dash/user/settings/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/user-account/settings/[subpage]/page.tsx similarity index 87% rename from apps/web/app/orgs/[orgslug]/dash/user/settings/[subpage]/page.tsx rename to apps/web/app/orgs/[orgslug]/dash/user-account/settings/[subpage]/page.tsx index 575b235f..f1ef8bcf 100644 --- a/apps/web/app/orgs/[orgslug]/dash/user/settings/[subpage]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/user-account/settings/[subpage]/page.tsx @@ -1,8 +1,8 @@ 'use client'; import React, { useEffect } from 'react' import { motion } from 'framer-motion'; -import UserEditGeneral from '@components/Dashboard/User/UserEditGeneral/UserEditGeneral'; -import UserEditPassword from '@components/Dashboard/User/UserEditPassword/UserEditPassword'; +import UserEditGeneral from '@components/Dashboard/UserAccount/UserEditGeneral/UserEditGeneral'; +import UserEditPassword from '@components/Dashboard/UserAccount/UserEditPassword/UserEditPassword'; import Link from 'next/link'; import { getUriWithOrg } from '@services/config/config'; import { Info, Lock } from 'lucide-react'; @@ -24,7 +24,7 @@ function SettingsPage({ params }: { params: SettingsParams }) { return (
-
+
@@ -32,7 +32,7 @@ function SettingsPage({ params }: { params: SettingsParams }) {
- +
@@ -41,7 +41,7 @@ function SettingsPage({ params }: { params: SettingsParams }) {
- +
@@ -58,6 +58,7 @@ function SettingsPage({ params }: { params: SettingsParams }) { animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.10, type: "spring", stiffness: 80 }} + className='h-full overflow-y-auto' > {params.subpage == 'general' ? : ''} {params.subpage == 'security' ? : ''} diff --git a/apps/web/app/orgs/[orgslug]/dash/users/settings/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/users/settings/[subpage]/page.tsx new file mode 100644 index 00000000..1f1806ea --- /dev/null +++ b/apps/web/app/orgs/[orgslug]/dash/users/settings/[subpage]/page.tsx @@ -0,0 +1,63 @@ +'use client'; +import React, { useEffect } from 'react' +import { motion } from 'framer-motion'; +import UserEditGeneral from '@components/Dashboard/UserAccount/UserEditGeneral/UserEditGeneral'; +import UserEditPassword from '@components/Dashboard/UserAccount/UserEditPassword/UserEditPassword'; +import Link from 'next/link'; +import { getUriWithOrg } from '@services/config/config'; +import { Info, Lock, User, Users } from 'lucide-react'; +import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs'; +import { useSession } from '@components/Contexts/SessionContext'; +import { useOrg } from '@components/Contexts/OrgContext'; +import OrgUsers from '@components/Dashboard/Users/OrgUsers/OrgUsers'; + +export type SettingsParams = { + subpage: string + orgslug: string +} + +function UsersSettingsPage({ params }: { params: SettingsParams }) { + const session = useSession() as any; + const org = useOrg() as any; + + + useEffect(() => { + } + , [session, org]) + + return ( +
+
+ +
+
+
Organization Users Settings
+
+
+
+ +
+ +
+ +
Users
+
+
+ + +
+
+ + {params.subpage == 'users' ? : ''} + +
+ ) +} + +export default UsersSettingsPage \ No newline at end of file diff --git a/apps/web/components/Dashboard/Course/EditCourseGeneral/ThumbnailUpdate.tsx b/apps/web/components/Dashboard/Course/EditCourseGeneral/ThumbnailUpdate.tsx index 68924b60..ac5ca37d 100644 --- a/apps/web/components/Dashboard/Course/EditCourseGeneral/ThumbnailUpdate.tsx +++ b/apps/web/components/Dashboard/Course/EditCourseGeneral/ThumbnailUpdate.tsx @@ -62,7 +62,7 @@ function ThumbnailUpdate() {