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() {
Create and manage courses, chapters and ativities
Configure your Organization general settings
Configure your personal settings, passwords, email
+Manage your Organization's users, roles
+Configure your personal settings, passwords, email