Merge pull request #155 from learnhouse/feat/usergroups

Implement UserGroups
This commit is contained in:
Badr B 2024-03-31 00:06:48 +01:00 committed by GitHub
commit e35487648e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 1970 additions and 149 deletions

View file

@ -19,6 +19,7 @@ class Permission(BaseModel):
class Rights(BaseModel): class Rights(BaseModel):
courses: Permission courses: Permission
users: Permission users: Permission
usergroups : Permission
collections: Permission collections: Permission
organizations: Permission organizations: Permission
coursechapters: Permission coursechapters: Permission

View file

@ -0,0 +1,16 @@
from typing import Optional
from sqlalchemy import Column, ForeignKey, Integer
from sqlmodel import Field, SQLModel
class UserGroupResource(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
usergroup_id: int = Field(
sa_column=Column(Integer, ForeignKey("usergroup.id", ondelete="CASCADE"))
)
resource_uuid: str = ""
org_id: int = Field(
sa_column=Column(Integer, ForeignKey("organization.id", ondelete="CASCADE"))
)
creation_date: str = ""
update_date: str = ""

View file

@ -0,0 +1,18 @@
from typing import Optional
from sqlalchemy import Column, ForeignKey, Integer
from sqlmodel import Field, SQLModel
class UserGroupUser(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
usergroup_id: int = Field(
sa_column=Column(Integer, ForeignKey("usergroup.id", ondelete="CASCADE"))
)
user_id: int = Field(
sa_column=Column(Integer, ForeignKey("user.id", ondelete="CASCADE"))
)
org_id: int = Field(
sa_column=Column(Integer, ForeignKey("organization.id", ondelete="CASCADE"))
)
creation_date: str = ""
update_date: str = ""

View file

@ -0,0 +1,33 @@
from typing import Optional
from sqlalchemy import Column, ForeignKey, Integer
from sqlmodel import Field, SQLModel
class UserGroupBase(SQLModel):
name: str
description: str
class UserGroup(UserGroupBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
org_id: int = Field(
sa_column=Column(Integer, ForeignKey("organization.id", ondelete="CASCADE"))
)
usergroup_uuid: str = ""
creation_date: str = ""
update_date: str = ""
class UserGroupCreate(UserGroupBase):
org_id: int = Field(default=None, foreign_key="organization.id")
pass
class UserGroupUpdate(UserGroupBase):
name: str
description: str
class UserGroupRead(UserGroupBase):
id: int
org_id: int = Field(default=None, foreign_key="organization.id")
usergroup_uuid: str
creation_date: str
update_date: str
pass

View file

@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from src.routers import usergroups
from src.routers import blocks, dev, trail, users, auth, orgs, roles from src.routers import blocks, dev, trail, users, auth, orgs, roles
from src.routers.ai import ai from src.routers.ai import ai
from src.routers.courses import chapters, collections, courses, activities from src.routers.courses import chapters, collections, courses, activities
@ -12,6 +13,7 @@ v1_router = APIRouter(prefix="/api/v1")
# API Routes # API Routes
v1_router.include_router(users.router, prefix="/users", tags=["users"]) v1_router.include_router(users.router, prefix="/users", tags=["users"])
v1_router.include_router(usergroups.router, prefix="/usergroups", tags=["usergroups"])
v1_router.include_router(auth.router, prefix="/auth", tags=["auth"]) v1_router.include_router(auth.router, prefix="/auth", tags=["auth"])
v1_router.include_router(orgs.router, prefix="/orgs", tags=["orgs"]) v1_router.include_router(orgs.router, prefix="/orgs", tags=["orgs"])
v1_router.include_router(roles.router, prefix="/roles", tags=["roles"]) v1_router.include_router(roles.router, prefix="/roles", tags=["roles"])

View file

@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Request, UploadFile
from sqlmodel import Session from sqlmodel import Session
from src.services.orgs.invites import ( from src.services.orgs.invites import (
create_invite_code, create_invite_code,
create_invite_code_with_usergroup,
delete_invite_code, delete_invite_code,
get_invite_code, get_invite_code,
get_invite_codes, get_invite_codes,
@ -162,6 +163,22 @@ async def api_create_invite_code(
return await create_invite_code(request, org_id, current_user, db_session) return await create_invite_code(request, org_id, current_user, db_session)
@router.post("/{org_id}/invites_with_usergroups")
async def api_create_invite_code_with_ug(
request: Request,
org_id: int,
usergroup_id: int,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
"""
Create invite code
"""
return await create_invite_code_with_usergroup(
request, org_id, usergroup_id, current_user, db_session
)
@router.get("/{org_id}/invites") @router.get("/{org_id}/invites")
async def api_get_invite_codes( async def api_get_invite_codes(
request: Request, request: Request,

View file

@ -0,0 +1,197 @@
from fastapi import APIRouter, Depends, Request
from sqlmodel import Session
from src.db.usergroups import UserGroupCreate, UserGroupRead, UserGroupUpdate
from src.db.users import PublicUser, UserRead
from src.services.users.usergroups import (
add_resources_to_usergroup,
add_users_to_usergroup,
create_usergroup,
delete_usergroup_by_id,
get_usergroups_by_resource,
get_users_linked_to_usergroup,
read_usergroup_by_id,
read_usergroups_by_org_id,
remove_resources_from_usergroup,
remove_users_from_usergroup,
update_usergroup_by_id,
)
from src.security.auth import get_current_user
from src.core.events.database import get_db_session
router = APIRouter()
@router.post("/", response_model=UserGroupRead, tags=["usergroups"])
async def api_create_usergroup(
*,
request: Request,
db_session: Session = Depends(get_db_session),
current_user: PublicUser = Depends(get_current_user),
usergroup_object: UserGroupCreate,
) -> UserGroupRead:
"""
Create User
"""
return await create_usergroup(request, db_session, current_user, usergroup_object)
@router.get("/{usergroup_id}", response_model=UserGroupRead, tags=["usergroups"])
async def api_get_usergroup(
*,
request: Request,
db_session: Session = Depends(get_db_session),
current_user: PublicUser = Depends(get_current_user),
usergroup_id: int,
) -> UserGroupRead:
"""
Get UserGroup
"""
return await read_usergroup_by_id(request, db_session, current_user, usergroup_id)
@router.get("/{usergroup_id}/users", response_model=list[UserRead], tags=["usergroups"])
async def api_get_users_linked_to_usergroup(
*,
request: Request,
db_session: Session = Depends(get_db_session),
current_user: PublicUser = Depends(get_current_user),
usergroup_id: int,
) -> list[UserRead]:
"""
Get Users linked to UserGroup
"""
return await get_users_linked_to_usergroup(
request, db_session, current_user, usergroup_id
)
@router.get("/org/{org_id}", response_model=list[UserGroupRead], tags=["usergroups"])
async def api_get_usergroups(
*,
request: Request,
db_session: Session = Depends(get_db_session),
current_user: PublicUser = Depends(get_current_user),
org_id: int,
) -> list[UserGroupRead]:
"""
Get UserGroups by Org
"""
return await read_usergroups_by_org_id(request, db_session, current_user, org_id)
@router.get(
"/resource/{resource_uuid}", response_model=list[UserGroupRead], tags=["usergroups"]
)
async def api_get_usergroupsby_resource(
*,
request: Request,
db_session: Session = Depends(get_db_session),
current_user: PublicUser = Depends(get_current_user),
resource_uuid: str,
) -> list[UserGroupRead]:
"""
Get UserGroups by Org
"""
return await get_usergroups_by_resource(
request, db_session, current_user, resource_uuid
)
@router.put("/{usergroup_id}", response_model=UserGroupRead, tags=["usergroups"])
async def api_update_usergroup(
*,
request: Request,
db_session: Session = Depends(get_db_session),
current_user: PublicUser = Depends(get_current_user),
usergroup_id: int,
usergroup_object: UserGroupUpdate,
) -> UserGroupRead:
"""
Update UserGroup
"""
return await update_usergroup_by_id(
request, db_session, current_user, usergroup_id, usergroup_object
)
@router.delete("/{usergroup_id}", tags=["usergroups"])
async def api_delete_usergroup(
*,
request: Request,
db_session: Session = Depends(get_db_session),
current_user: PublicUser = Depends(get_current_user),
usergroup_id: int,
) -> str:
"""
Delete UserGroup
"""
return await delete_usergroup_by_id(request, db_session, current_user, usergroup_id)
@router.post("/{usergroup_id}/add_users", tags=["usergroups"])
async def api_add_users_to_usergroup(
*,
request: Request,
db_session: Session = Depends(get_db_session),
current_user: PublicUser = Depends(get_current_user),
usergroup_id: int,
user_ids: str,
) -> str:
"""
Add Users to UserGroup
"""
return await add_users_to_usergroup(
request, db_session, current_user, usergroup_id, user_ids
)
@router.delete("/{usergroup_id}/remove_users", tags=["usergroups"])
async def api_delete_users_from_usergroup(
*,
request: Request,
db_session: Session = Depends(get_db_session),
current_user: PublicUser = Depends(get_current_user),
usergroup_id: int,
user_ids: str,
) -> str:
"""
Delete Users from UserGroup
"""
return await remove_users_from_usergroup(
request, db_session, current_user, usergroup_id, user_ids
)
@router.post("/{usergroup_id}/add_resources", tags=["usergroups"])
async def api_add_resources_to_usergroup(
*,
request: Request,
db_session: Session = Depends(get_db_session),
current_user: PublicUser = Depends(get_current_user),
usergroup_id: int,
resource_uuids: str,
) -> str:
"""
Add Resources to UserGroup
"""
return await add_resources_to_usergroup(
request, db_session, current_user, usergroup_id, resource_uuids
)
@router.delete("/{usergroup_id}/remove_resources", tags=["usergroups"])
async def api_delete_resources_from_usergroup(
*,
request: Request,
db_session: Session = Depends(get_db_session),
current_user: PublicUser = Depends(get_current_user),
usergroup_id: int,
resource_uuids: str,
) -> str:
"""
Delete Resources from UserGroup
"""
return await remove_resources_from_usergroup(
request, db_session, current_user, usergroup_id, resource_uuids
)

View file

@ -143,7 +143,7 @@ async def authorization_verify_based_on_org_admin_status(
# Tested and working # Tested and working
async def authorization_verify_based_on_roles_and_authorship( async def authorization_verify_based_on_roles_and_authorship_and_usergroups(
request: Request, request: Request,
user_id: int, user_id: int,
action: Literal["read", "update", "delete", "create"], action: Literal["read", "update", "delete", "create"],

View file

@ -1,25 +1,27 @@
from fastapi import HTTPException, status from fastapi import HTTPException, status
async def check_element_type(element_id): async def check_element_type(element_uuid):
""" """
Check if the element is a course, a user, a house or a collection, by checking its prefix Check if the element is a course, a user, a house or a collection, by checking its prefix
""" """
if element_id.startswith("course_"): if element_uuid.startswith("course_"):
return "courses" return "courses"
elif element_id.startswith("user_"): elif element_uuid.startswith("user_"):
return "users" return "users"
elif element_id.startswith("house_"): elif element_uuid.startswith("usergroup_"):
return "usergroups"
elif element_uuid.startswith("house_"):
return "houses" return "houses"
elif element_id.startswith("org_"): elif element_uuid.startswith("org_"):
return "organizations" return "organizations"
elif element_id.startswith("chapter_"): elif element_uuid.startswith("chapter_"):
return "coursechapters" return "coursechapters"
elif element_id.startswith("collection_"): elif element_uuid.startswith("collection_"):
return "collections" return "collections"
elif element_id.startswith("activity_"): elif element_uuid.startswith("activity_"):
return "activities" return "activities"
elif element_id.startswith("role_"): elif element_uuid.startswith("role_"):
return "roles" return "roles"
else: else:
raise HTTPException( raise HTTPException(
@ -28,8 +30,8 @@ async def check_element_type(element_id):
) )
async def get_singular_form_of_element(element_id): async def get_singular_form_of_element(element_uuid):
element_type = await check_element_type(element_id) element_type = await check_element_type(element_uuid)
if element_type == "activities": if element_type == "activities":
return "activity" return "activity"
@ -38,8 +40,8 @@ async def get_singular_form_of_element(element_id):
return singular_form_element return singular_form_element
async def get_id_identifier_of_element(element_id): async def get_id_identifier_of_element(element_uuid):
singular_form_element = await get_singular_form_of_element(element_id) singular_form_element = await get_singular_form_of_element(element_uuid)
if singular_form_element == "ogranizations": if singular_form_element == "ogranizations":
return "org_id" return "org_id"

View file

@ -3,7 +3,7 @@ from sqlmodel import Session, select
from src.db.courses import Course from src.db.courses import Course
from src.db.chapters import Chapter from src.db.chapters import Chapter
from src.security.rbac.rbac import ( from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship, authorization_verify_based_on_roles_and_authorship_and_usergroups,
authorization_verify_if_element_is_public, authorization_verify_if_element_is_public,
authorization_verify_if_user_is_anon, authorization_verify_if_user_is_anon,
) )
@ -238,14 +238,14 @@ async def rbac_check(
) )
return res return res
else: else:
res = await authorization_verify_based_on_roles_and_authorship( res = await authorization_verify_based_on_roles_and_authorship_and_usergroups(
request, current_user.id, action, course_uuid, db_session request, current_user.id, action, course_uuid, db_session
) )
return res return res
else: else:
await authorization_verify_if_user_is_anon(current_user.id) await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship( await authorization_verify_based_on_roles_and_authorship_and_usergroups(
request, request,
current_user.id, current_user.id,
action, action,

View file

@ -3,7 +3,7 @@ from src.db.courses import Course
from src.db.organizations import Organization from src.db.organizations import Organization
from sqlmodel import Session, select from sqlmodel import Session, select
from src.security.rbac.rbac import ( from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship, authorization_verify_based_on_roles_and_authorship_and_usergroups,
authorization_verify_if_user_is_anon, authorization_verify_if_user_is_anon,
) )
from src.db.chapters import Chapter from src.db.chapters import Chapter
@ -150,7 +150,7 @@ async def rbac_check(
): ):
await authorization_verify_if_user_is_anon(current_user.id) await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship( await authorization_verify_based_on_roles_and_authorship_and_usergroups(
request, request,
current_user.id, current_user.id,
action, action,

View file

@ -5,7 +5,7 @@ from src.db.organizations import Organization
from pydantic import BaseModel from pydantic import BaseModel
from sqlmodel import Session, select from sqlmodel import Session, select
from src.security.rbac.rbac import ( from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship, authorization_verify_based_on_roles_and_authorship_and_usergroups,
authorization_verify_if_user_is_anon, authorization_verify_if_user_is_anon,
) )
from src.db.chapters import Chapter from src.db.chapters import Chapter
@ -232,7 +232,7 @@ async def rbac_check(
): ):
await authorization_verify_if_user_is_anon(current_user.id) await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship( await authorization_verify_based_on_roles_and_authorship_and_usergroups(
request, request,
current_user.id, current_user.id,
action, action,

View file

@ -4,7 +4,7 @@ from uuid import uuid4
from sqlmodel import Session, select from sqlmodel import Session, select
from src.db.users import AnonymousUser from src.db.users import AnonymousUser
from src.security.rbac.rbac import ( from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship, authorization_verify_based_on_roles_and_authorship_and_usergroups,
authorization_verify_if_element_is_public, authorization_verify_if_element_is_public,
authorization_verify_if_user_is_anon, authorization_verify_if_user_is_anon,
) )
@ -559,17 +559,16 @@ async def rbac_check(
res = await authorization_verify_if_element_is_public( res = await authorization_verify_if_element_is_public(
request, course_uuid, action, db_session request, course_uuid, action, db_session
) )
print("res", res)
return res return res
else: else:
res = await authorization_verify_based_on_roles_and_authorship( res = await authorization_verify_based_on_roles_and_authorship_and_usergroups(
request, current_user.id, action, course_uuid, db_session request, current_user.id, action, course_uuid, db_session
) )
return res return res
else: else:
await authorization_verify_if_user_is_anon(current_user.id) await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship( await authorization_verify_based_on_roles_and_authorship_and_usergroups(
request, request,
current_user.id, current_user.id,
action, action,

View file

@ -4,7 +4,7 @@ from uuid import uuid4
from sqlmodel import Session, select from sqlmodel import Session, select
from src.db.users import AnonymousUser from src.db.users import AnonymousUser
from src.security.rbac.rbac import ( from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship, authorization_verify_based_on_roles_and_authorship_and_usergroups,
authorization_verify_if_element_is_public, authorization_verify_if_element_is_public,
authorization_verify_if_user_is_anon, authorization_verify_if_user_is_anon,
) )
@ -297,14 +297,14 @@ async def rbac_check(
detail="User rights : You are not allowed to read this collection", detail="User rights : You are not allowed to read this collection",
) )
else: else:
res = await authorization_verify_based_on_roles_and_authorship( res = await authorization_verify_based_on_roles_and_authorship_and_usergroups(
request, current_user.id, action, collection_uuid, db_session request, current_user.id, action, collection_uuid, db_session
) )
return res return res
else: else:
await authorization_verify_if_user_is_anon(current_user.id) await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship( await authorization_verify_based_on_roles_and_authorship_and_usergroups(
request, request,
current_user.id, current_user.id,
action, action,

View file

@ -1,9 +1,11 @@
from typing import Literal from typing import Literal
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import union
from sqlmodel import Session, select from sqlmodel import Session, select
from src.db.usergroup_resources import UserGroupResource
from src.db.usergroup_user import UserGroupUser
from src.db.organizations import Organization from src.db.organizations import Organization
from src.db.trails import TrailRead from src.db.trails import TrailRead
from src.services.trail.trail import get_user_trail_with_orgid from src.services.trail.trail import get_user_trail_with_orgid
from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum
from src.db.users import PublicUser, AnonymousUser, User, UserRead from src.db.users import PublicUser, AnonymousUser, User, UserRead
@ -15,7 +17,7 @@ from src.db.courses import (
FullCourseReadWithTrail, FullCourseReadWithTrail,
) )
from src.security.rbac.rbac import ( from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship, authorization_verify_based_on_roles_and_authorship_and_usergroups,
authorization_verify_if_element_is_public, authorization_verify_if_element_is_public,
authorization_verify_if_user_is_anon, authorization_verify_if_user_is_anon,
) )
@ -106,7 +108,6 @@ async def get_course_meta(
) )
trail = TrailRead.from_orm(trail) trail = TrailRead.from_orm(trail)
return FullCourseReadWithTrail( return FullCourseReadWithTrail(
**course.dict(), **course.dict(),
chapters=chapters, chapters=chapters,
@ -142,7 +143,7 @@ async def create_course(
if thumbnail_file and thumbnail_file.filename: if thumbnail_file and thumbnail_file.filename:
name_in_disk = f"{course.course_uuid}_thumbnail_{uuid4()}.{thumbnail_file.filename.split('.')[-1]}" name_in_disk = f"{course.course_uuid}_thumbnail_{uuid4()}.{thumbnail_file.filename.split('.')[-1]}"
await upload_thumbnail( await upload_thumbnail(
thumbnail_file, name_in_disk, org.org_uuid, course.course_uuid thumbnail_file, name_in_disk, org.org_uuid, course.course_uuid # type: ignore
) )
course.thumbnail_image = name_in_disk course.thumbnail_image = name_in_disk
@ -213,7 +214,7 @@ async def update_course_thumbnail(
if thumbnail_file and thumbnail_file.filename: if thumbnail_file and thumbnail_file.filename:
name_in_disk = f"{course_uuid}_thumbnail_{uuid4()}.{thumbnail_file.filename.split('.')[-1]}" name_in_disk = f"{course_uuid}_thumbnail_{uuid4()}.{thumbnail_file.filename.split('.')[-1]}"
await upload_thumbnail( await upload_thumbnail(
thumbnail_file, name_in_disk, org.org_uuid, course.course_uuid thumbnail_file, name_in_disk, org.org_uuid, course.course_uuid # type: ignore
) )
# Update course # Update course
@ -327,26 +328,47 @@ async def get_courses_orgslug(
page: int = 1, page: int = 1,
limit: int = 10, limit: int = 10,
): ):
# Query for public courses
statement_public = ( statement_public = (
select(Course) select(Course)
.join(Organization) .join(Organization)
.where(Organization.slug == org_slug, Course.public == True) .where(Organization.slug == org_slug, Course.public == True)
) )
statement_all = (
select(Course).join(Organization).where(Organization.slug == org_slug) # Query for courses where the current user is an author
statement_author = (
select(Course)
.join(Organization)
.join(ResourceAuthor, ResourceAuthor.user_id == current_user.id)
.where(
Organization.slug == org_slug,
ResourceAuthor.resource_uuid == Course.course_uuid,
)
) )
if current_user.id == 0: # Query for courses where the current user is in a user group that has access to the course
statement = statement_public statement_usergroup = (
else: select(Course)
# RBAC check .join(Organization)
await authorization_verify_if_user_is_anon(current_user.id) .join(
UserGroupResource, UserGroupResource.resource_uuid == Course.course_uuid
)
.join(
UserGroupUser, UserGroupUser.usergroup_id == UserGroupResource.usergroup_id
)
.where(Organization.slug == org_slug, UserGroupUser.user_id == current_user.id)
)
statement = statement_all # Combine the results
statement_complete = union(
statement_public, statement_author, statement_usergroup
).subquery()
courses = db_session.exec(statement) courses = db_session.execute(select([statement_complete])).all()
courses = [CourseRead(**course.dict(), authors=[]) for course in courses] # TODO: I have no idea why this is necessary, but it is
courses = [CourseRead(**dict(course._mapping), authors=[]) for course in courses]
# for every course, get the authors # for every course, get the authors
for course in courses: for course in courses:
@ -367,6 +389,7 @@ async def get_courses_orgslug(
## 🔒 RBAC Utils ## ## 🔒 RBAC Utils ##
async def rbac_check( async def rbac_check(
request: Request, request: Request,
course_uuid: str, course_uuid: str,
@ -381,14 +404,16 @@ async def rbac_check(
) )
return res return res
else: else:
res = await authorization_verify_based_on_roles_and_authorship( res = (
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
request, current_user.id, action, course_uuid, db_session request, current_user.id, action, course_uuid, db_session
) )
)
return res return res
else: else:
await authorization_verify_if_user_is_anon(current_user.id) await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship( await authorization_verify_based_on_roles_and_authorship_and_usergroups(
request, request,
current_user.id, current_user.id,
action, action,

View file

@ -135,6 +135,12 @@ async def install_default_elements(db_session: Session):
action_update=True, action_update=True,
action_delete=True, action_delete=True,
), ),
usergroups=Permission(
action_create=True,
action_read=True,
action_update=True,
action_delete=True,
),
collections=Permission( collections=Permission(
action_create=True, action_create=True,
action_read=True, action_read=True,
@ -183,6 +189,12 @@ async def install_default_elements(db_session: Session):
action_update=True, action_update=True,
action_delete=True, action_delete=True,
), ),
usergroups=Permission(
action_create=True,
action_read=True,
action_update=True,
action_delete=True,
),
collections=Permission( collections=Permission(
action_create=True, action_create=True,
action_read=True, action_read=True,
@ -231,6 +243,12 @@ async def install_default_elements(db_session: Session):
action_update=False, action_update=False,
action_delete=False, action_delete=False,
), ),
usergroups=Permission(
action_create=False,
action_read=True,
action_update=False,
action_delete=False,
),
collections=Permission( collections=Permission(
action_create=False, action_create=False,
action_read=True, action_read=True,

View file

@ -94,6 +94,85 @@ async def create_invite_code(
return inviteCodeObject return inviteCodeObject
async def create_invite_code_with_usergroup(
request: Request,
org_id: int,
usergroup_id: int,
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
# Redis init
LH_CONFIG = get_learnhouse_config()
redis_conn_string = LH_CONFIG.redis_config.redis_connection_string
if not redis_conn_string:
raise HTTPException(
status_code=500,
detail="Redis connection string not found",
)
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, "update", db_session)
# Connect to Redis
r = redis.Redis.from_url(redis_conn_string)
if not r:
raise HTTPException(
status_code=500,
detail="Could not connect to Redis",
)
# Check if this org has more than 6 invite codes
invite_codes = r.keys(f"*:org:{org.org_uuid}:code:*")
if len(invite_codes) >= 6:
raise HTTPException(
status_code=400,
detail="Organization has reached the maximum number of invite codes",
)
# Generate invite code
def generate_code(length=5):
letters_and_digits = string.ascii_letters + string.digits
return "".join(random.choice(letters_and_digits) for _ in range(length))
generated_invite_code = generate_code()
invite_code_uuid = f"org_invite_code_{uuid.uuid4()}"
# time to live in days to seconds
ttl = int(timedelta(days=365).total_seconds())
inviteCodeObject = {
"invite_code": generated_invite_code,
"invite_code_uuid": invite_code_uuid,
"invite_code_expires": ttl,
"usergroup_id": usergroup_id,
"invite_code_type": "signup",
"created_at": datetime.now().isoformat(),
"created_by": current_user.user_uuid,
}
r.set(
f"{invite_code_uuid}:org:{org.org_uuid}:code:{generated_invite_code}",
json.dumps(inviteCodeObject),
ex=ttl,
)
return inviteCodeObject
async def get_invite_codes( async def get_invite_codes(
request: Request, request: Request,
org_id: int, org_id: int,
@ -136,9 +215,11 @@ async def get_invite_codes(
# Get invite codes # Get invite codes
invite_codes = r.keys(f"org_invite_code_*:org:{org.org_uuid}:code:*") invite_codes = r.keys(f"org_invite_code_*:org:{org.org_uuid}:code:*")
invite_codes_list = [] invite_codes_list = []
for invite_code in invite_codes: for invite_code in invite_codes: # type: ignore
invite_code = r.get(invite_code) invite_code = r.get(invite_code)
invite_code = json.loads(invite_code) # type: ignore invite_code = json.loads(invite_code) # type: ignore
invite_codes_list.append(invite_code) invite_codes_list.append(invite_code)

View file

@ -2,7 +2,7 @@ from typing import Literal
from uuid import uuid4 from uuid import uuid4
from sqlmodel import Session, select from sqlmodel import Session, select
from src.security.rbac.rbac import ( from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship, authorization_verify_based_on_roles_and_authorship_and_usergroups,
authorization_verify_if_user_is_anon, authorization_verify_if_user_is_anon,
) )
from src.db.users import AnonymousUser, PublicUser from src.db.users import AnonymousUser, PublicUser
@ -133,7 +133,7 @@ async def rbac_check(
): ):
await authorization_verify_if_user_is_anon(current_user.id) await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship( await authorization_verify_based_on_roles_and_authorship_and_usergroups(
request, current_user.id, action, role_uuid, db_session request, current_user.id, action, role_uuid, db_session
) )

View file

@ -0,0 +1,491 @@
from datetime import datetime
import logging
from typing import Literal
from uuid import uuid4
from fastapi import HTTPException, Request
from sqlmodel import Session, select
from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship_and_usergroups,
authorization_verify_if_user_is_anon,
)
from src.db.usergroup_resources import UserGroupResource
from src.db.usergroup_user import UserGroupUser
from src.db.organizations import Organization
from src.db.usergroups import UserGroup, UserGroupCreate, UserGroupRead, UserGroupUpdate
from src.db.users import AnonymousUser, PublicUser, User, UserRead
async def create_usergroup(
request: Request,
db_session: Session,
current_user: PublicUser | AnonymousUser,
usergroup_create: UserGroupCreate,
) -> UserGroupRead:
usergroup = UserGroup.from_orm(usergroup_create)
# RBAC check
await rbac_check(
request,
usergroup_uuid="usergroup_X",
current_user=current_user,
action="create",
db_session=db_session,
)
# Check if Organization exists
statement = select(Organization).where(Organization.id == usergroup_create.org_id)
result = db_session.exec(statement)
if not result.first():
raise HTTPException(
status_code=400,
detail="Organization does not exist",
)
# Complete the object
usergroup.usergroup_uuid = f"usergroup_{uuid4()}"
usergroup.creation_date = str(datetime.now())
usergroup.update_date = str(datetime.now())
# Save the object
db_session.add(usergroup)
db_session.commit()
db_session.refresh(usergroup)
usergroup = UserGroupRead.from_orm(usergroup)
return usergroup
async def read_usergroup_by_id(
request: Request,
db_session: Session,
current_user: PublicUser | AnonymousUser,
usergroup_id: int,
) -> UserGroupRead:
statement = select(UserGroup).where(UserGroup.id == usergroup_id)
usergroup = db_session.exec(statement).first()
if not usergroup:
raise HTTPException(
status_code=404,
detail="UserGroup not found",
)
# RBAC check
await rbac_check(
request,
usergroup_uuid=usergroup.usergroup_uuid,
current_user=current_user,
action="read",
db_session=db_session,
)
usergroup = UserGroupRead.from_orm(usergroup)
return usergroup
async def get_users_linked_to_usergroup(
request: Request,
db_session: Session,
current_user: PublicUser | AnonymousUser,
usergroup_id: int,
) -> list[UserRead]:
statement = select(UserGroup).where(UserGroup.id == usergroup_id)
usergroup = db_session.exec(statement).first()
if not usergroup:
raise HTTPException(
status_code=404,
detail="UserGroup not found",
)
# RBAC check
await rbac_check(
request,
usergroup_uuid=usergroup.usergroup_uuid,
current_user=current_user,
action="read",
db_session=db_session,
)
statement = select(UserGroupUser).where(UserGroupUser.usergroup_id == usergroup_id)
usergroup_users = db_session.exec(statement).all()
user_ids = [usergroup_user.user_id for usergroup_user in usergroup_users]
# get users
users = []
for user_id in user_ids:
statement = select(User).where(User.id == user_id)
user = db_session.exec(statement).first()
users.append(user)
users = [UserRead.from_orm(user) for user in users]
return users
async def read_usergroups_by_org_id(
request: Request,
db_session: Session,
current_user: PublicUser | AnonymousUser,
org_id: int,
) -> list[UserGroupRead]:
statement = select(UserGroup).where(UserGroup.org_id == org_id)
usergroups = db_session.exec(statement).all()
# RBAC check
await rbac_check(
request,
usergroup_uuid="usergroup_X",
current_user=current_user,
action="read",
db_session=db_session,
)
usergroups = [UserGroupRead.from_orm(usergroup) for usergroup in usergroups]
return usergroups
async def get_usergroups_by_resource(
request: Request,
db_session: Session,
current_user: PublicUser | AnonymousUser,
resource_uuid: str,
) -> list[UserGroupRead]:
statement = select(UserGroupResource).where(
UserGroupResource.resource_uuid == resource_uuid
)
usergroup_resources = db_session.exec(statement).all()
# RBAC check
await rbac_check(
request,
usergroup_uuid="usergroup_X",
current_user=current_user,
action="read",
db_session=db_session,
)
usergroup_ids = [usergroup.usergroup_id for usergroup in usergroup_resources]
# get usergroups
usergroups = []
for usergroup_id in usergroup_ids:
statement = select(UserGroup).where(UserGroup.id == usergroup_id)
usergroup = db_session.exec(statement).first()
usergroups.append(usergroup)
usergroups = [UserGroupRead.from_orm(usergroup) for usergroup in usergroups]
return usergroups
async def update_usergroup_by_id(
request: Request,
db_session: Session,
current_user: PublicUser | AnonymousUser,
usergroup_id: int,
usergroup_update: UserGroupUpdate,
) -> UserGroupRead:
statement = select(UserGroup).where(UserGroup.id == usergroup_id)
usergroup = db_session.exec(statement).first()
if not usergroup:
raise HTTPException(
status_code=404,
detail="UserGroup not found",
)
# RBAC check
await rbac_check(
request,
usergroup_uuid=usergroup.usergroup_uuid,
current_user=current_user,
action="update",
db_session=db_session,
)
usergroup.name = usergroup_update.name
usergroup.description = usergroup_update.description
usergroup.update_date = str(datetime.now())
db_session.add(usergroup)
db_session.commit()
db_session.refresh(usergroup)
usergroup = UserGroupRead.from_orm(usergroup)
return usergroup
async def delete_usergroup_by_id(
request: Request,
db_session: Session,
current_user: PublicUser | AnonymousUser,
usergroup_id: int,
) -> str:
statement = select(UserGroup).where(UserGroup.id == usergroup_id)
usergroup = db_session.exec(statement).first()
if not usergroup:
raise HTTPException(
status_code=404,
detail="UserGroup not found",
)
# RBAC check
await rbac_check(
request,
usergroup_uuid=usergroup.usergroup_uuid,
current_user=current_user,
action="delete",
db_session=db_session,
)
db_session.delete(usergroup)
db_session.commit()
return "UserGroup deleted successfully"
async def add_users_to_usergroup(
request: Request,
db_session: Session,
current_user: PublicUser | AnonymousUser,
usergroup_id: int,
user_ids: str,
) -> str:
statement = select(UserGroup).where(UserGroup.id == usergroup_id)
usergroup = db_session.exec(statement).first()
if not usergroup:
raise HTTPException(
status_code=404,
detail="UserGroup not found",
)
# RBAC check
await rbac_check(
request,
usergroup_uuid=usergroup.usergroup_uuid,
current_user=current_user,
action="create",
db_session=db_session,
)
user_ids_array = user_ids.split(",")
for user_id in user_ids_array:
statement = select(User).where(User.id == user_id)
user = db_session.exec(statement).first()
# Check if User is already Linked to UserGroup
statement = select(UserGroupUser).where(
UserGroupUser.usergroup_id == usergroup_id,
UserGroupUser.user_id == user_id,
)
usergroup_user = db_session.exec(statement).first()
if usergroup_user:
logging.error(f"User with id {user_id} already exists in UserGroup")
continue
if user:
# Add user to UserGroup
if user.id is not None:
usergroup_obj = UserGroupUser(
usergroup_id=usergroup_id,
user_id=user.id,
org_id=usergroup.org_id,
creation_date=str(datetime.now()),
update_date=str(datetime.now()),
)
db_session.add(usergroup_obj)
db_session.commit()
db_session.refresh(usergroup_obj)
else:
logging.error(f"User with id {user_id} not found")
return "Users added to UserGroup successfully"
async def remove_users_from_usergroup(
request: Request,
db_session: Session,
current_user: PublicUser | AnonymousUser,
usergroup_id: int,
user_ids: str,
) -> str:
statement = select(UserGroup).where(UserGroup.id == usergroup_id)
usergroup = db_session.exec(statement).first()
if not usergroup:
raise HTTPException(
status_code=404,
detail="UserGroup not found",
)
# RBAC check
await rbac_check(
request,
usergroup_uuid=usergroup.usergroup_uuid,
current_user=current_user,
action="delete",
db_session=db_session,
)
user_ids_array = user_ids.split(",")
for user_id in user_ids_array:
statement = select(UserGroupUser).where(UserGroupUser.user_id == user_id, UserGroupUser.usergroup_id == usergroup_id)
usergroup_user = db_session.exec(statement).first()
if usergroup_user:
db_session.delete(usergroup_user)
db_session.commit()
else:
logging.error(f"User with id {user_id} not found in UserGroup")
return "Users removed from UserGroup successfully"
async def add_resources_to_usergroup(
request: Request,
db_session: Session,
current_user: PublicUser | AnonymousUser,
usergroup_id: int,
resources_uuids: str,
) -> str:
statement = select(UserGroup).where(UserGroup.id == usergroup_id)
usergroup = db_session.exec(statement).first()
if not usergroup:
raise HTTPException(
status_code=404,
detail="UserGroup not found",
)
# RBAC check
await rbac_check(
request,
usergroup_uuid=usergroup.usergroup_uuid,
current_user=current_user,
action="create",
db_session=db_session,
)
resources_uuids_array = resources_uuids.split(",")
for resource_uuid in resources_uuids_array:
# Check if a link between UserGroup and Resource already exists
statement = select(UserGroupResource).where(
UserGroupResource.usergroup_id == usergroup_id,
UserGroupResource.resource_uuid == resource_uuid,
)
usergroup_resource = db_session.exec(statement).first()
if usergroup_resource:
raise HTTPException(
status_code=400,
detail=f"Resource {resource_uuid} already exists in UserGroup",
)
continue
# TODO : Find a way to check if resource really exists
usergroup_obj = UserGroupResource(
usergroup_id=usergroup_id,
resource_uuid=resource_uuid,
org_id=usergroup.org_id,
creation_date=str(datetime.now()),
update_date=str(datetime.now()),
)
db_session.add(usergroup_obj)
db_session.commit()
db_session.refresh(usergroup_obj)
return "Resources added to UserGroup successfully"
async def remove_resources_from_usergroup(
request: Request,
db_session: Session,
current_user: PublicUser | AnonymousUser,
usergroup_id: int,
resources_uuids: str,
) -> str:
statement = select(UserGroup).where(UserGroup.id == usergroup_id)
usergroup = db_session.exec(statement).first()
if not usergroup:
raise HTTPException(
status_code=404,
detail="UserGroup not found",
)
# RBAC check
await rbac_check(
request,
usergroup_uuid=usergroup.usergroup_uuid,
current_user=current_user,
action="delete",
db_session=db_session,
)
resources_uuids_array = resources_uuids.split(",")
for resource_uuid in resources_uuids_array:
statement = select(UserGroupResource).where(
UserGroupResource.resource_uuid == resource_uuid
)
usergroup_resource = db_session.exec(statement).first()
if usergroup_resource:
db_session.delete(usergroup_resource)
db_session.commit()
else:
logging.error(f"resource with uuid {resource_uuid} not found in UserGroup")
return "Resources removed from UserGroup successfully"
## 🔒 RBAC Utils ##
async def rbac_check(
request: Request,
usergroup_uuid: str,
current_user: PublicUser | AnonymousUser,
action: Literal["create", "read", "update", "delete"],
db_session: Session,
):
await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
request,
current_user.id,
action,
usergroup_uuid,
db_session,
)
## 🔒 RBAC Utils ##

View file

@ -3,6 +3,7 @@ from typing import Literal
from uuid import uuid4 from uuid import uuid4
from fastapi import HTTPException, Request, UploadFile, status from fastapi import HTTPException, Request, UploadFile, status
from sqlmodel import Session, select from sqlmodel import Session, select
from src.services.users.usergroups import add_users_to_usergroup
from src.services.users.emails import ( from src.services.users.emails import (
send_account_creation_email, send_account_creation_email,
) )
@ -10,7 +11,7 @@ from src.services.orgs.invites import get_invite_code
from src.services.users.avatars import upload_avatar from src.services.users.avatars import upload_avatar
from src.db.roles import Role, RoleRead from src.db.roles import Role, RoleRead
from src.security.rbac.rbac import ( from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship, authorization_verify_based_on_roles_and_authorship_and_usergroups,
authorization_verify_if_user_is_anon, authorization_verify_if_user_is_anon,
) )
from src.db.organizations import Organization, OrganizationRead from src.db.organizations import Organization, OrganizationRead
@ -124,16 +125,27 @@ async def create_user_with_invite(
): ):
# Check if invite code exists # Check if invite code exists
isInviteCodeCorrect = await get_invite_code( inviteCode = await get_invite_code(
request, org_id, invite_code, current_user, db_session request, org_id, invite_code, current_user, db_session
) )
if not isInviteCodeCorrect: if not inviteCode:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="Invite code is incorrect", detail="Invite code is incorrect",
) )
# Check if invite code contains UserGroup
if inviteCode.usergroup_id:
# Add user to UserGroup
await add_users_to_usergroup(
request,
db_session,
current_user,
inviteCode.usergroup_id,
user_object.username,
)
user = await create_user(request, db_session, current_user, user_object, org_id) user = await create_user(request, db_session, current_user, user_object, org_id)
return user return user
@ -346,6 +358,7 @@ async def update_user_password(
return user return user
async def read_user_by_id( async def read_user_by_id(
request: Request, request: Request,
db_session: Session, db_session: Session,
@ -449,7 +462,7 @@ async def authorize_user_action(
request: Request, request: Request,
db_session: Session, db_session: Session,
current_user: PublicUser | AnonymousUser, current_user: PublicUser | AnonymousUser,
ressource_uuid: str, resource_uuid: str,
action: Literal["create", "read", "update", "delete"], action: Literal["create", "read", "update", "delete"],
): ):
# Get user # Get user
@ -463,8 +476,10 @@ async def authorize_user_action(
) )
# RBAC check # RBAC check
authorized = await authorization_verify_based_on_roles_and_authorship( authorized = (
request, current_user.id, action, ressource_uuid, db_session await authorization_verify_based_on_roles_and_authorship_and_usergroups(
request, current_user.id, action, resource_uuid, db_session
)
) )
if authorized: if authorized:
@ -535,7 +550,7 @@ async def rbac_check(
if current_user.id == 0: # if user is anonymous if current_user.id == 0: # if user is anonymous
return True return True
else: else:
await authorization_verify_based_on_roles_and_authorship( await authorization_verify_based_on_roles_and_authorship_and_usergroups(
request, current_user.id, "create", "user_x", db_session request, current_user.id, "create", "user_x", db_session
) )
@ -546,7 +561,7 @@ async def rbac_check(
if current_user.user_uuid == user_uuid: if current_user.user_uuid == user_uuid:
return True return True
await authorization_verify_based_on_roles_and_authorship( await authorization_verify_based_on_roles_and_authorship_and_usergroups(
request, current_user.id, action, user_uuid, db_session request, current_user.id, action, user_uuid, db_session
) )

View file

@ -7,7 +7,8 @@ import Link from 'next/link'
import { CourseOverviewTop } from '@components/Dashboard/UI/CourseOverviewTop' import { CourseOverviewTop } from '@components/Dashboard/UI/CourseOverviewTop'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import EditCourseGeneral from '@components/Dashboard/Course/EditCourseGeneral/EditCourseGeneral' import EditCourseGeneral from '@components/Dashboard/Course/EditCourseGeneral/EditCourseGeneral'
import { GalleryVerticalEnd, Info } from 'lucide-react' import { GalleryVerticalEnd, Info, Lock, UserRoundCog } from 'lucide-react'
import EditCourseAccess from '@components/Dashboard/Course/EditCourseAccess/EditCourseAccess'
export type CourseOverviewParams = { export type CourseOverviewParams = {
orgslug: string orgslug: string
@ -24,9 +25,9 @@ function CourseOverviewPage({ params }: { params: CourseOverviewParams }) {
return ( return (
<div className="h-screen w-full bg-[#f8f8f8] grid grid-rows-[auto,1fr]"> <div className="h-screen w-full bg-[#f8f8f8] grid grid-rows-[auto,1fr]">
<CourseProvider courseuuid={getEntireCourseUUID(params.courseuuid)}> <CourseProvider courseuuid={getEntireCourseUUID(params.courseuuid)}>
<div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] z-10 shadow-[0px_4px_16px_rgba(0,0,0,0.06)]"> <div className="pl-10 pr-10 text-sm tracking-tight bg-[#fcfbfc] z-10 shadow-[0px_4px_16px_rgba(0,0,0,0.06)]">
<CourseOverviewTop params={params} /> <CourseOverviewTop params={params} />
<div className="flex space-x-5 font-black text-sm"> <div className="flex space-x-3 font-black text-sm">
<Link <Link
href={ href={
getUriWithOrg(params.orgslug, '') + getUriWithOrg(params.orgslug, '') +
@ -34,8 +35,7 @@ function CourseOverviewPage({ params }: { params: CourseOverviewParams }) {
} }
> >
<div <div
className={`py-2 w-fit text-center border-black transition-all ease-linear ${ className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'general'
params.subpage.toString() === 'general'
? 'border-b-4' ? 'border-b-4'
: 'opacity-50' : 'opacity-50'
} cursor-pointer`} } cursor-pointer`}
@ -46,6 +46,24 @@ function CourseOverviewPage({ params }: { params: CourseOverviewParams }) {
</div> </div>
</div> </div>
</Link> </Link>
<Link
href={
getUriWithOrg(params.orgslug, '') +
`/dash/courses/course/${params.courseuuid}/access`
}
>
<div
className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'access'
? 'border-b-4'
: 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2">
<UserRoundCog size={16} />
<div>Access</div>
</div>
</div>
</Link>
<Link <Link
href={ href={
getUriWithOrg(params.orgslug, '') + getUriWithOrg(params.orgslug, '') +
@ -53,8 +71,7 @@ function CourseOverviewPage({ params }: { params: CourseOverviewParams }) {
} }
> >
<div <div
className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${ className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'content'
params.subpage.toString() === 'content'
? 'border-b-4' ? 'border-b-4'
: 'opacity-50' : 'opacity-50'
} cursor-pointer`} } cursor-pointer`}
@ -65,7 +82,9 @@ function CourseOverviewPage({ params }: { params: CourseOverviewParams }) {
</div> </div>
</div> </div>
</Link> </Link>
</div> </div>
</div> </div>
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@ -74,16 +93,9 @@ function CourseOverviewPage({ params }: { params: CourseOverviewParams }) {
transition={{ duration: 0.1, type: 'spring', stiffness: 80 }} transition={{ duration: 0.1, type: 'spring', stiffness: 80 }}
className="h-full overflow-y-auto" className="h-full overflow-y-auto"
> >
{params.subpage == 'content' ? ( {params.subpage == 'content' ? (<EditCourseStructure orgslug={params.orgslug} />) : ('')}
<EditCourseStructure orgslug={params.orgslug} /> {params.subpage == 'general' ? (<EditCourseGeneral orgslug={params.orgslug} />) : ('')}
) : ( {params.subpage == 'access' ? (<EditCourseAccess orgslug={params.orgslug} />) : ('')}
''
)}
{params.subpage == 'general' ? (
<EditCourseGeneral orgslug={params.orgslug} />
) : (
''
)}
</motion.div> </motion.div>
</CourseProvider> </CourseProvider>
</div> </div>

View file

@ -3,13 +3,14 @@ import React, { useEffect } from 'react'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import Link from 'next/link' import Link from 'next/link'
import { getUriWithOrg } from '@services/config/config' import { getUriWithOrg } from '@services/config/config'
import { ScanEye, UserPlus, Users } from 'lucide-react' import { ScanEye, SquareUserRound, UserPlus, Users } from 'lucide-react'
import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs' import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs'
import { useSession } from '@components/Contexts/SessionContext' import { useSession } from '@components/Contexts/SessionContext'
import { useOrg } from '@components/Contexts/OrgContext' import { useOrg } from '@components/Contexts/OrgContext'
import OrgUsers from '@components/Dashboard/Users/OrgUsers/OrgUsers' import OrgUsers from '@components/Dashboard/Users/OrgUsers/OrgUsers'
import OrgAccess from '@components/Dashboard/Users/OrgAccess/OrgAccess' import OrgAccess from '@components/Dashboard/Users/OrgAccess/OrgAccess'
import OrgUsersAdd from '@components/Dashboard/Users/OrgUsersAdd/OrgUsersAdd' import OrgUsersAdd from '@components/Dashboard/Users/OrgUsersAdd/OrgUsersAdd'
import OrgUserGroups from '@components/Dashboard/Users/OrgUserGroups/OrgUserGroups'
export type SettingsParams = { export type SettingsParams = {
subpage: string subpage: string
@ -28,12 +29,16 @@ function UsersSettingsPage({ params }: { params: SettingsParams }) {
setH2Label('Manage your organization users, assign roles and permissions') setH2Label('Manage your organization users, assign roles and permissions')
} }
if (params.subpage == 'signups') { if (params.subpage == 'signups') {
setH1Label('Signup Access') setH1Label('Signups & Invite Codes')
setH2Label('Choose from where users can join your organization') setH2Label('Choose from where users can join your organization')
} }
if (params.subpage == 'add') { if (params.subpage == 'add') {
setH1Label('Invite users') setH1Label('Invite Members')
setH2Label('Invite users to join your organization') setH2Label('Invite members to join your organization')
}
if (params.subpage == 'usergroups') {
setH1Label('UserGroups')
setH2Label('Create and manage user groups')
} }
} }
@ -62,8 +67,7 @@ function UsersSettingsPage({ params }: { params: SettingsParams }) {
} }
> >
<div <div
className={`py-2 w-fit text-center border-black transition-all ease-linear ${ className={`py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'users'
params.subpage.toString() === 'users'
? 'border-b-4' ? 'border-b-4'
: 'opacity-50' : 'opacity-50'
} cursor-pointer`} } cursor-pointer`}
@ -76,19 +80,18 @@ function UsersSettingsPage({ params }: { params: SettingsParams }) {
</Link> </Link>
<Link <Link
href={ href={
getUriWithOrg(params.orgslug, '') + `/dash/users/settings/add` getUriWithOrg(params.orgslug, '') + `/dash/users/settings/usergroups`
} }
> >
<div <div
className={`py-2 w-fit text-center border-black transition-all ease-linear ${ className={`py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'usergroups'
params.subpage.toString() === 'add'
? 'border-b-4' ? 'border-b-4'
: 'opacity-50' : 'opacity-50'
} cursor-pointer`} } cursor-pointer`}
> >
<div className="flex items-center space-x-2.5 mx-2"> <div className="flex items-center space-x-2.5 mx-2">
<UserPlus size={16} /> <SquareUserRound size={16} />
<div>Invite users</div> <div>UserGroups</div>
</div> </div>
</div> </div>
</Link> </Link>
@ -98,18 +101,35 @@ function UsersSettingsPage({ params }: { params: SettingsParams }) {
} }
> >
<div <div
className={`py-2 w-fit text-center border-black transition-all ease-linear ${ className={`py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'signups'
params.subpage.toString() === 'signups'
? 'border-b-4' ? 'border-b-4'
: 'opacity-50' : 'opacity-50'
} cursor-pointer`} } cursor-pointer`}
> >
<div className="flex items-center space-x-2.5 mx-2"> <div className="flex items-center space-x-2.5 mx-2">
<ScanEye size={16} /> <ScanEye size={16} />
<div>Signup Access</div> <div>Signups & Invite Codes</div>
</div> </div>
</div> </div>
</Link> </Link>
<Link
href={
getUriWithOrg(params.orgslug, '') + `/dash/users/settings/add`
}
>
<div
className={`py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'add'
? 'border-b-4'
: 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2">
<UserPlus size={16} />
<div>Invite Members</div>
</div>
</div>
</Link>
</div> </div>
</div> </div>
<motion.div <motion.div
@ -122,6 +142,7 @@ function UsersSettingsPage({ params }: { params: SettingsParams }) {
{params.subpage == 'users' ? <OrgUsers /> : ''} {params.subpage == 'users' ? <OrgUsers /> : ''}
{params.subpage == 'signups' ? <OrgAccess /> : ''} {params.subpage == 'signups' ? <OrgAccess /> : ''}
{params.subpage == 'add' ? <OrgUsersAdd /> : ''} {params.subpage == 'add' ? <OrgUsersAdd /> : ''}
{params.subpage == 'usergroups' ? <OrgUserGroups /> : ''}
</motion.div> </motion.div>
</div> </div>
) )

View file

@ -1,6 +1,7 @@
'use client' 'use client'
import { OrgProvider } from '@components/Contexts/OrgContext' import { OrgProvider } from '@components/Contexts/OrgContext'
import SessionProvider from '@components/Contexts/SessionContext' import SessionProvider from '@components/Contexts/SessionContext'
import Toast from '@components/StyledElements/Toast/Toast'
import '@styles/globals.css' import '@styles/globals.css'
export default function RootLayout({ export default function RootLayout({
@ -12,6 +13,7 @@ export default function RootLayout({
}) { }) {
return ( return (
<div> <div>
<Toast />
<OrgProvider orgslug={params.orgslug}> <OrgProvider orgslug={params.orgslug}>
<SessionProvider>{children}</SessionProvider> <SessionProvider>{children}</SessionProvider>
</OrgProvider> </OrgProvider>

View file

@ -66,6 +66,7 @@ function InviteOnlySignUpComponent(props: InviteOnlySignUpProps) {
last_name: '', last_name: '',
}, },
validate, validate,
enableReinitialize: true,
onSubmit: async (values) => { onSubmit: async (values) => {
setError('') setError('')
setMessage('') setMessage('')

View file

@ -62,6 +62,7 @@ function OpenSignUpComponent() {
last_name: '', last_name: '',
}, },
validate, validate,
enableReinitialize: true,
onSubmit: async (values) => { onSubmit: async (values) => {
setError('') setError('')
setMessage('') setMessage('')

View file

@ -6,7 +6,7 @@ import Link from 'next/link'
import { getUriWithOrg } from '@services/config/config' import { getUriWithOrg } from '@services/config/config'
import { useSession } from '@components/Contexts/SessionContext' import { useSession } from '@components/Contexts/SessionContext'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { MailWarning, Shield, UserPlus } from 'lucide-react' import { MailWarning, Shield, Ticket, UserPlus } from 'lucide-react'
import { useOrg } from '@components/Contexts/OrgContext' import { useOrg } from '@components/Contexts/OrgContext'
import UserAvatar from '@components/Objects/UserAvatar' import UserAvatar from '@components/Objects/UserAvatar'
import OpenSignUpComponent from './OpenSignup' import OpenSignUpComponent from './OpenSignup'
@ -201,7 +201,7 @@ const NoTokenScreen = (props: any) => {
onClick={validateCode} onClick={validateCode}
className="flex w-fit space-x-2 bg-black px-6 py-2 text-md rounded-lg font-semibold h-fit text-white items-center shadow-md" className="flex w-fit space-x-2 bg-black px-6 py-2 text-md rounded-lg font-semibold h-fit text-white items-center shadow-md"
> >
<Shield size={18} /> <Ticket size={18} />
<p>Submit </p> <p>Submit </p>
</button> </button>
</div> </div>

View file

@ -0,0 +1,213 @@
import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext'
import LinkToUserGroup from '@components/Objects/Modals/Dash/EditCourseAccess/LinkToUserGroup'
import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal'
import Modal from '@components/StyledElements/Modal/Modal'
import { getAPIUrl } from '@services/config/config'
import { unLinkResourcesToUserGroup } from '@services/usergroups/usergroups'
import { swrFetcher } from '@services/utils/ts/requests'
import { Globe, SquareUserRound, Users, UsersRound, X } from 'lucide-react'
import React from 'react'
import toast from 'react-hot-toast'
import useSWR, { mutate } from 'swr'
type EditCourseAccessProps = {
orgslug: string
course_uuid?: string
}
function EditCourseAccess(props: EditCourseAccessProps) {
const [error, setError] = React.useState('')
const course = useCourse() as any
const dispatchCourse = useCourseDispatch() as any
const courseStructure = course.courseStructure
const { data: usergroups } = useSWR(
courseStructure ? `${getAPIUrl()}usergroups/resource/${courseStructure.course_uuid}` : null,
swrFetcher
)
const [isPublic, setIsPublic] = React.useState(courseStructure.public)
React.useEffect(() => {
// This code will run whenever form values are updated
if (isPublic !== courseStructure.public) {
dispatchCourse({ type: 'setIsNotSaved' })
const updatedCourse = {
...courseStructure,
public: isPublic,
}
dispatchCourse({ type: 'setCourseStructure', payload: updatedCourse })
}
}, [course, isPublic])
return (
<div>
{' '}
<div className="h-6"></div>
<div className="ml-10 mr-10 mx-auto bg-white rounded-xl shadow-sm px-4 py-4">
<div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 rounded-md mb-3 ">
<h1 className="font-bold text-xl text-gray-800">Access to the course</h1>
<h2 className="text-gray-500 text-sm">
{' '}
Choose if want your course to be publicly available on the internet or only accessible to signed in users{' '}
</h2>
</div>
<div className="flex space-x-2 mx-auto mb-3">
<ConfirmationModal
confirmationButtonText="Change to Public"
confirmationMessage="Are you sure you want this course to be publicly available on the internet ?"
dialogTitle={'Change to Public ?'}
dialogTrigger={
<div className="w-full h-[200px] bg-slate-100 rounded-lg cursor-pointer hover:bg-slate-200 ease-linear transition-all">
{isPublic ? (
<div className="bg-green-200 text-green-600 font-bold w-fit my-3 mx-3 absolute text-sm px-3 py-1 rounded-lg">
Active
</div>
) : null}
<div className="flex flex-col space-y-1 justify-center items-center h-full">
<Globe className="text-slate-400" size={40}></Globe>
<div className="text-2xl text-slate-700 font-bold">
Public
</div>
<div className="text-gray-400 text-md tracking-tight w-[500px] leading-5 text-center">
The Course is publicly available on the internet, it is indexed by search engines and can be accessed by anyone
</div>
</div>
</div>
}
functionToExecute={() => {
setIsPublic(true)
}}
status="info"
></ConfirmationModal>
<ConfirmationModal
confirmationButtonText="Change to Users Only"
confirmationMessage="Are you sure you want this course to be only accessible to signed in users ?"
dialogTitle={'Change to Users Only ?'}
dialogTrigger={
<div className="w-full h-[200px] bg-slate-100 rounded-lg cursor-pointer hover:bg-slate-200 ease-linear transition-all">
{!isPublic ? (
<div className="bg-green-200 text-green-600 font-bold w-fit my-3 mx-3 absolute text-sm px-3 py-1 rounded-lg">
Active
</div>
) : null}
<div className="flex flex-col space-y-1 justify-center items-center h-full">
<Users className="text-slate-400" size={40}></Users>
<div className="text-2xl text-slate-700 font-bold">
Users Only
</div>
<div className="text-gray-400 text-md tracking-tight w-[500px] leading-5 text-center">
The Course is only accessible to signed in users, additionaly you can choose which UserGroups can access this course
</div>
</div>
</div>
}
functionToExecute={() => {
setIsPublic(false)
}}
status="info"
></ConfirmationModal>
</div>
{!isPublic ? ( <UserGroupsSection usergroups={usergroups} />) : null}
</div>
</div>
)
}
function UserGroupsSection({ usergroups }: { usergroups: any[] }) {
const course = useCourse() as any
const [userGroupModal, setUserGroupModal] = React.useState(false)
const removeUserGroupLink = async (usergroup_id: number) => {
const res = await unLinkResourcesToUserGroup(usergroup_id, course.courseStructure.course_uuid)
if (res.status === 200) {
toast.success('Successfully unliked from usergroup')
mutate(`${getAPIUrl()}usergroups/resource/${course.courseStructure.course_uuid}`)
}
else {
toast.error('Error ' + res.status + ': ' + res.data.detail)
}
}
return (
<>
<div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 rounded-md mb-3 ">
<h1 className="font-bold text-xl text-gray-800">UserGroups</h1>
<h2 className="text-gray-500 text-sm">
{' '}
You can choose to give access to this course to specific groups of users only by linking it to a UserGroup{' '}
</h2>
</div>
<table className="table-auto w-full text-left whitespace-nowrap rounded-md overflow-hidden">
<thead className="bg-gray-100 text-gray-500 rounded-xl uppercase">
<tr className="font-bolder text-sm">
<th className="py-3 px-4">Name</th>
<th className="py-3 px-4">Actions</th>
</tr>
</thead>
<>
<tbody className="mt-5 bg-white rounded-md">
{usergroups?.map((usergroup: any) => (
<tr
key={usergroup.invite_code_uuid}
className="border-b border-gray-100 text-sm"
>
<td className="py-3 px-4">{usergroup.name}</td>
<td className="py-3 px-4">
<ConfirmationModal
confirmationButtonText="Delete Link"
confirmationMessage="Users from this UserGroup will no longer have access to this course"
dialogTitle={'Unlink UserGroup ?'}
dialogTrigger={
<button className="mr-2 flex space-x-2 hover:cursor-pointer p-1 px-3 bg-rose-700 rounded-md font-bold items-center text-sm text-rose-100">
<X className="w-4 h-4" />
<span> Delete link</span>
</button>
}
functionToExecute={() => {
removeUserGroupLink(usergroup.id)
}}
status="warning"
></ConfirmationModal>
</td>
</tr>
))}
</tbody>
</>
</table>
<div className='flex flex-row-reverse mt-3 mr-2'>
<Modal
isDialogOpen={
userGroupModal
}
onOpenChange={() =>
setUserGroupModal(!userGroupModal)
}
minHeight="no-min"
minWidth='md'
dialogContent={
<LinkToUserGroup setUserGroupModal={setUserGroupModal} />
}
dialogTitle="Link Course to a UserGroup"
dialogDescription={
'Choose a UserGroup to link this course to, Users from this UserGroup will have access to this course.'
}
dialogTrigger={
<button
className=" flex space-x-2 hover:cursor-pointer p-1 px-3 bg-green-700 rounded-md font-bold items-center text-sm text-green-100"
>
<SquareUserRound className="w-4 h-4" />
<span>Link to a UserGroup</span>
</button>
}
/>
</div>
</>
)
}
export default EditCourseAccess

View file

@ -116,10 +116,11 @@ function EditCourseGeneral(props: EditCourseStructureProps) {
message={formik.errors.description} message={formik.errors.description}
/> />
<Form.Control asChild> <Form.Control asChild>
<Textarea <Input
style={{ backgroundColor: 'white' }} style={{ backgroundColor: 'white' }}
onChange={formik.handleChange} onChange={formik.handleChange}
value={formik.values.description} value={formik.values.description}
type='text'
required required
/> />
</Form.Control> </Form.Control>
@ -177,26 +178,7 @@ function EditCourseGeneral(props: EditCourseStructureProps) {
</Form.Control> </Form.Control>
</FormField> </FormField>
<FormField className="flex items-center h-10" name="public">
<div className="flex my-auto items-center">
<label
className="text-black text-[15px] leading-none pr-[15px]"
htmlFor="public-course"
>
Public Course
</label>
<Switch.Root
className="w-[42px] h-[25px] bg-neutral-200 rounded-full relative data-[state=checked]:bg-neutral-500 outline-none cursor-default"
id="public-course"
onCheckedChange={(checked) =>
formik.setFieldValue('public', checked)
}
checked={formik.values.public === 'true'}
>
<Switch.Thumb className="block w-[21px] h-[21px] bg-white rounded-full shadow-[0_2px_2px] shadow-neutral-300 transition-transform duration-100 translate-x-0.5 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
</FormField>
</FormLayout> </FormLayout>
</div> </div>
)} )}

View file

@ -46,18 +46,17 @@ function ThumbnailUpdate() {
{localThumbnail ? ( {localThumbnail ? (
<img <img
src={URL.createObjectURL(localThumbnail)} src={URL.createObjectURL(localThumbnail)}
className={`${ className={`${isLoading ? 'animate-pulse' : ''
isLoading ? 'animate-pulse' : ''
} shadow w-[200px] h-[100px] rounded-md`} } shadow w-[200px] h-[100px] rounded-md`}
/> />
) : ( ) : (
<img <img
src={`${getCourseThumbnailMediaDirectory( src={`${course.courseStructure.thumbnail_image ? getCourseThumbnailMediaDirectory(
org?.org_uuid, org?.org_uuid,
course.courseStructure.course_uuid, course.courseStructure.course_uuid,
course.courseStructure.thumbnail_image course.courseStructure.thumbnail_image
)}`} ) : '/empty_thumbnail.png'}`}
className="shadow w-[200px] h-[100px] rounded-md" className="shadow w-[200px] h-[100px] rounded-md bg-gray-200"
/> />
)} )}
</div> </div>

View file

@ -64,6 +64,7 @@ function BreadCrumbs(props: BreadCrumbsProps) {
</div> </div>
</div> </div>
</div> </div>
<div className="h-2"></div>
</div> </div>
) )
} }

View file

@ -27,7 +27,7 @@ export function CourseOverviewTop({
last_breadcrumb={course.courseStructure.name} last_breadcrumb={course.courseStructure.name}
></BreadCrumbs> ></BreadCrumbs>
<div className="flex"> <div className="flex">
<div className="flex py-5 grow items-center"> <div className="flex py-3 grow items-center">
<Link <Link
href={getUriWithOrg(org?.slug, '') + `/course/${params.courseuuid}`} href={getUriWithOrg(org?.slug, '') + `/course/${params.courseuuid}`}
> >

View file

@ -3,7 +3,7 @@ import PageLoading from '@components/Objects/Loaders/PageLoading'
import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal' import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal'
import { getAPIUrl, getUriWithOrg } from '@services/config/config' import { getAPIUrl, getUriWithOrg } from '@services/config/config'
import { swrFetcher } from '@services/utils/ts/requests' import { swrFetcher } from '@services/utils/ts/requests'
import { Globe, Shield, X } from 'lucide-react' import { Globe, Shield, Ticket, User, UserSquare, Users, X } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import useSWR, { mutate } from 'swr' import useSWR, { mutate } from 'swr'
@ -16,6 +16,8 @@ import {
import Toast from '@components/StyledElements/Toast/Toast' import Toast from '@components/StyledElements/Toast/Toast'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Modal from '@components/StyledElements/Modal/Modal'
import OrgInviteCodeGenerate from '@components/Objects/Modals/Dash/OrgAccess/OrgInviteCodeGenerate'
function OrgAccess() { function OrgAccess() {
const org = useOrg() as any const org = useOrg() as any
@ -25,6 +27,7 @@ function OrgAccess() {
) )
const [isLoading, setIsLoading] = React.useState(false) const [isLoading, setIsLoading] = React.useState(false)
const [joinMethod, setJoinMethod] = React.useState('closed') const [joinMethod, setJoinMethod] = React.useState('closed')
const [invitesModal, setInvitesModal] = React.useState(false)
const router = useRouter() const router = useRouter()
async function getOrgJoinMethod() { async function getOrgJoinMethod() {
@ -37,14 +40,7 @@ function OrgAccess() {
} }
} }
async function createInvite() {
let res = await createInviteCode(org.id)
if (res.status == 200) {
mutate(`${getAPIUrl()}orgs/${org.id}/invites`)
} else {
toast.error('Error ' + res.status + ': ' + res.data.detail)
}
}
async function deleteInvite(invite: any) { async function deleteInvite(invite: any) {
let res = await deleteInviteCode(org.id, invite.invite_code_uuid) let res = await deleteInviteCode(org.id, invite.invite_code_uuid)
@ -74,7 +70,6 @@ function OrgAccess() {
return ( return (
<> <>
<Toast></Toast>
{!isLoading ? ( {!isLoading ? (
<> <>
<div className="h-6"></div> <div className="h-6"></div>
@ -126,7 +121,7 @@ function OrgAccess() {
</div> </div>
) : null} ) : null}
<div className="flex flex-col space-y-1 justify-center items-center h-full"> <div className="flex flex-col space-y-1 justify-center items-center h-full">
<Shield className="text-slate-400" size={40}></Shield> <Ticket className="text-slate-400" size={40}></Ticket>
<div className="text-2xl text-slate-700 font-bold"> <div className="text-2xl text-slate-700 font-bold">
Closed Closed
</div> </div>
@ -162,6 +157,7 @@ function OrgAccess() {
<tr className="font-bolder text-sm"> <tr className="font-bolder text-sm">
<th className="py-3 px-4">Code</th> <th className="py-3 px-4">Code</th>
<th className="py-3 px-4">Signup link</th> <th className="py-3 px-4">Signup link</th>
<th className="py-3 px-4">Type</th>
<th className="py-3 px-4">Expiration date</th> <th className="py-3 px-4">Expiration date</th>
<th className="py-3 px-4">Actions</th> <th className="py-3 px-4">Actions</th>
</tr> </tr>
@ -189,6 +185,19 @@ function OrgAccess() {
)} )}
</Link> </Link>
</td> </td>
<td className="py-3 px-4">
{invite.usergroup_id ? (
<div className="flex space-x-2 items-center">
<UserSquare className="w-4 h-4" />
<span>Linked to a UserGroup</span>
</div>
) : (
<div className="flex space-x-2 items-center">
<Users className="w-4 h-4" />
<span>Normal</span>
</div>
)}
</td>
<td className="py-3 px-4"> <td className="py-3 px-4">
{dayjs(invite.expiration_date) {dayjs(invite.expiration_date)
.add(1, 'year') .add(1, 'year')
@ -216,13 +225,36 @@ function OrgAccess() {
</tbody> </tbody>
</> </>
</table> </table>
<div className='flex flex-row-reverse mt-3 mr-2'><button <div className='flex flex-row-reverse mt-3 mr-2'>
onClick={() => createInvite()} <Modal
isDialogOpen={
invitesModal
}
onOpenChange={() =>
setInvitesModal(!invitesModal)
}
minHeight="no-min"
minWidth='lg'
dialogContent={
<OrgInviteCodeGenerate
setInvitesModal={setInvitesModal}
/>
}
dialogTitle="Generate Invite Code"
dialogDescription={
'Generate a new invite code for your organization'
}
dialogTrigger={
<button
className=" flex space-x-2 hover:cursor-pointer p-1 px-3 bg-green-700 rounded-md font-bold items-center text-sm text-green-100" className=" flex space-x-2 hover:cursor-pointer p-1 px-3 bg-green-700 rounded-md font-bold items-center text-sm text-green-100"
> >
<Shield className="w-4 h-4" /> <Ticket className="w-4 h-4" />
<span> Generate invite code</span> <span> Generate invite code</span>
</button></div> </button>
}
/>
</div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,156 @@
'use client'
import { useOrg } from '@components/Contexts/OrgContext'
import AddUserGroup from '@components/Objects/Modals/Dash/OrgUserGroups/AddUserGroup'
import ManageUsers from '@components/Objects/Modals/Dash/OrgUserGroups/ManageUsers'
import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal'
import Modal from '@components/StyledElements/Modal/Modal'
import { getAPIUrl } from '@services/config/config'
import { deleteUserGroup } from '@services/usergroups/usergroups'
import { swrFetcher } from '@services/utils/ts/requests'
import { SquareUserRound, Users, X } from 'lucide-react'
import React from 'react'
import toast from 'react-hot-toast'
import useSWR, { mutate } from 'swr'
function OrgUserGroups() {
const org = useOrg() as any
const [userGroupManagementModal, setUserGroupManagementModal] = React.useState(false)
const [createUserGroupModal, setCreateUserGroupModal] = React.useState(false)
const [selectedUserGroup, setSelectedUserGroup] = React.useState(null) as any
const { data: usergroups } = useSWR(
org ? `${getAPIUrl()}usergroups/org/${org.id}` : null,
swrFetcher
)
const deleteUserGroupUI = async (usergroup_id: any) => {
const res = await deleteUserGroup(usergroup_id)
if (res.status == 200) {
mutate(`${getAPIUrl()}usergroups/org/${org.id}`)
}
else {
toast.error('Error ' + res.status + ': ' + res.data.detail)
}
}
const handleUserGroupManagementModal = (usergroup_id: any) => {
setSelectedUserGroup(usergroup_id)
setUserGroupManagementModal(!userGroupManagementModal)
}
return (
<>
<div className="h-6"></div>
<div className="ml-10 mr-10 mx-auto bg-white rounded-xl shadow-sm px-4 py-4">
<div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 rounded-md mb-3 ">
<h1 className="font-bold text-xl text-gray-800">Manage UserGroups & Users</h1>
<h2 className="text-gray-500 text-sm">
{' '}
UserGroups are a way to group users together to manage their access to the resources (Courses) in your organization.{' '}
</h2>
</div>
<table className="table-auto w-full text-left whitespace-nowrap rounded-md overflow-hidden">
<thead className="bg-gray-100 text-gray-500 rounded-xl uppercase">
<tr className="font-bolder text-sm">
<th className="py-3 px-4">UserGroup</th>
<th className="py-3 px-4">Description</th>
<th className="py-3 px-4">Manage Users</th>
<th className="py-3 px-4">Actions</th>
</tr>
</thead>
<>
<tbody className="mt-5 bg-white rounded-md">
{usergroups?.map((usergroup: any) => (
<tr key={usergroup.id} className="border-b border-gray-100 text-sm">
<td className="py-3 px-4">{usergroup.name}</td>
<td className="py-3 px-4 ">{usergroup.description}</td>
<td className="py-3 px-4 ">
<Modal
isDialogOpen={
userGroupManagementModal &&
selectedUserGroup === usergroup.id
}
onOpenChange={() =>
handleUserGroupManagementModal(usergroup.id)
}
minHeight="lg"
minWidth='lg'
dialogContent={
<ManageUsers
usergroup_id={usergroup.id}
/>
}
dialogTitle="Manage UserGroup Users"
dialogDescription={
'Manage the users in this UserGroup'
}
dialogTrigger={
<button className="flex space-x-2 hover:cursor-pointer p-1 px-3 bg-yellow-700 rounded-md font-bold items-center text-sm text-yellow-100">
<Users className="w-4 h-4" />
<span> Manage Users</span>
</button>
}
/>
</td>
<td className="py-3 px-4 ">
<ConfirmationModal
confirmationButtonText="Delete UserGroup"
confirmationMessage="Access to all resources will be removed for all users in this UserGroup. Are you sure you want to delete this UserGroup ?"
dialogTitle={'Delete UserGroup ?'}
dialogTrigger={
<button className="flex space-x-2 hover:cursor-pointer p-1 px-3 bg-rose-700 rounded-md font-bold items-center text-sm text-rose-100">
<X className="w-4 h-4" />
<span> Delete</span>
</button>
}
functionToExecute={() => {
deleteUserGroupUI(usergroup.id)
}}
status="warning"
></ConfirmationModal>
</td>
</tr>
))}
</tbody>
</>
</table>
<div className='flex justify-end mt-3 mr-2'>
<Modal
isDialogOpen={
createUserGroupModal
}
onOpenChange={() =>
setCreateUserGroupModal(!createUserGroupModal)
}
minHeight="no-min"
dialogContent={
<AddUserGroup
setCreateUserGroupModal={setCreateUserGroupModal}
/>
}
dialogTitle="Create a UserGroup"
dialogDescription={
'Create a new UserGroup to manage users'
}
dialogTrigger={
<button
className=" flex space-x-2 hover:cursor-pointer p-1 px-3 bg-green-700 rounded-md font-bold items-center text-sm text-green-100"
>
<SquareUserRound className="w-4 h-4" />
<span>Create a UserGroup</span>
</button>
}
/>
</div>
</div>
</>
)
}
export default OrgUserGroups

View file

@ -0,0 +1,79 @@
'use client';
import { useCourse } from '@components/Contexts/CourseContext';
import { useOrg } from '@components/Contexts/OrgContext';
import { getAPIUrl } from '@services/config/config';
import { linkResourcesToUserGroup } from '@services/usergroups/usergroups';
import { swrFetcher } from '@services/utils/ts/requests';
import { AlertTriangle, Info } from 'lucide-react';
import React, { useEffect } from 'react'
import toast from 'react-hot-toast';
import useSWR, { mutate } from 'swr'
type LinkToUserGroupProps = {
// React function, todo: fix types
setUserGroupModal: any
}
function LinkToUserGroup(props: LinkToUserGroupProps) {
const course = useCourse() as any
const org = useOrg() as any
const courseStructure = course.courseStructure
const { data: usergroups } = useSWR(
courseStructure && org ? `${getAPIUrl()}usergroups/org/${org.id}` : null,
swrFetcher
)
const [selectedUserGroup, setSelectedUserGroup] = React.useState(null) as any
const handleLink = async () => {
console.log('selectedUserGroup', selectedUserGroup)
const res = await linkResourcesToUserGroup(selectedUserGroup, courseStructure.course_uuid)
if (res.status === 200) {
props.setUserGroupModal(false)
toast.success('Successfully linked to usergroup')
mutate(`${getAPIUrl()}usergroups/resource/${courseStructure.course_uuid}`)
}
else {
toast.error('Error ' + res.status + ': ' + res.data.detail)
}
}
useEffect(() => {
if (usergroups && usergroups.length > 0) {
setSelectedUserGroup(usergroups[0].id)
}
}
, [usergroups])
return (
<div className='flex flex-col space-y-1 '>
<div className='flex bg-yellow-100 text-yellow-900 mx-auto w-fit mt-3 px-4 py-2 space-x-2 text-sm rounded-full items-center'>
<Info size={19} />
<h1 className=' font-medium'>Users that are not part of the UserGroup will no longer have access to this course</h1>
</div>
<div className='p-4 flex-row flex justify-between items-center'>
<div className='py-1'>
<span className='px-3 text-gray-400 font-bold rounded-full py-1 bg-gray-100 mx-3'>UserGroup Name </span>
<select
onChange={(e) => setSelectedUserGroup(e.target.value)}
defaultValue={selectedUserGroup}
>
{usergroups && usergroups.map((group: any) => (
<option key={group.id} value={group.id}>{group.name}</option>
))}
</select>
</div>
<div className='py-3'>
<button onClick={() => { handleLink() }} className='bg-green-700 text-white font-bold px-4 py-2 rounded-md shadow'>Link</button>
</div>
</div>
</div>
)
}
export default LinkToUserGroup

View file

@ -0,0 +1,95 @@
import { useOrg } from '@components/Contexts/OrgContext'
import { getAPIUrl } from '@services/config/config'
import { createInviteCode, createInviteCodeWithUserGroup } from '@services/organizations/invites'
import { swrFetcher } from '@services/utils/ts/requests'
import { Shield, Ticket } from 'lucide-react'
import React, { useEffect } from 'react'
import toast from 'react-hot-toast'
import useSWR, { mutate } from 'swr'
type OrgInviteCodeGenerateProps = {
setInvitesModal: any
}
function OrgInviteCodeGenerate(props: OrgInviteCodeGenerateProps) {
const org = useOrg() as any
const [usergroup_id, setUsergroup_id] = React.useState(0);
const { data: usergroups } = useSWR(
org ? `${getAPIUrl()}usergroups/org/${org.id}` : null,
swrFetcher
)
async function createInviteWithUserGroup() {
let res = await createInviteCodeWithUserGroup(org.id, usergroup_id)
if (res.status == 200) {
mutate(`${getAPIUrl()}orgs/${org.id}/invites`)
props.setInvitesModal(false)
} else {
toast.error('Error ' + res.status + ': ' + res.data.detail)
}
}
async function createInvite() {
let res = await createInviteCode(org.id)
if (res.status == 200) {
mutate(`${getAPIUrl()}orgs/${org.id}/invites`)
props.setInvitesModal(false)
} else {
toast.error('Error ' + res.status + ': ' + res.data.detail)
}
}
useEffect(() => {
if (usergroups && usergroups.length > 0) {
setUsergroup_id(usergroups[0].id)
}
}
, [usergroups])
return (
<div className='flex space-x-2 pt-2'>
<div className='flex bg-slate-100 w-full h-[140px] rounded-lg'>
<div className='flex flex-col mx-auto'>
<h1 className='mx-auto pt-4 text-gray-600 font-medium'>Invite Code linked to a UserGroup</h1>
<h2 className='mx-auto text-xs text-gray-600 font-medium'>On Signup, Users will be automatically linked to a UserGroup of your choice</h2>
<div className='flex items-center space-x-4 pt-3 mx-auto'>
<select
defaultValue={usergroup_id}
className='flex p-2 w-fit rounded-md text-sm bg-gray-100'>
{usergroups?.map((usergroup: any) => (
<option key={usergroup.id} value={usergroup.id}>
{usergroup.name}
</option>
))}
</select>
<div className=''>
<button
onClick={createInviteWithUserGroup}
className="flex space-x-2 w-fit hover:cursor-pointer p-1 px-3 bg-green-700 rounded-md font-bold items-center text-sm text-green-100"
>
<Ticket className="w-4 h-4" />
<span> Generate </span>
</button>
</div>
</div>
</div>
</div>
<div className='flex bg-slate-100 w-full h-[140px] rounded-lg'>
<div className='flex flex-col mx-auto'>
<h1 className='mx-auto pt-4 text-gray-600 font-medium'>Normal Invite Code</h1>
<h2 className='mx-auto text-xs text-gray-600 font-medium'>On Signup, User will not be linked to any UserGroup</h2>
<div className='mx-auto pt-4'>
<button
onClick={createInvite}
className="flex space-x-2 w-fit hover:cursor-pointer p-1 px-3 bg-green-700 rounded-md font-bold items-center text-sm text-green-100"
>
<Ticket className="w-4 h-4" />
<span> Generate </span>
</button>
</div>
</div>
</div>
</div>
)
}
export default OrgInviteCodeGenerate

View file

@ -0,0 +1,99 @@
'use client'
import FormLayout, {
ButtonBlack,
Flex,
FormField,
FormLabel,
FormMessage,
Input,
} from '@components/StyledElements/Form/Form'
import * as Form from '@radix-ui/react-form'
import { useOrg } from '@components/Contexts/OrgContext'
import React from 'react'
import { BarLoader } from 'react-spinners'
import { createUserGroup } from '@services/usergroups/usergroups'
import { mutate } from 'swr'
import { getAPIUrl } from '@services/config/config'
type AddUserGroupProps = {
setCreateUserGroupModal: any
}
function AddUserGroup(props: AddUserGroupProps) {
const org = useOrg() as any
const [userGroupName, setUserGroupName] = React.useState('')
const [userGroupDescription, setUserGroupDescription] = React.useState('')
const [isSubmitting, setIsSubmitting] = React.useState(false)
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setUserGroupName(event.target.value)
}
const handleDescriptionChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setUserGroupDescription(event.target.value)
}
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
setIsSubmitting(true)
const obj = {
name: userGroupName,
description: userGroupDescription,
org_id: org.id
}
const res = await createUserGroup(obj)
if (res.status == 200) {
setIsSubmitting(false)
mutate(`${getAPIUrl()}usergroups/org/${org.id}`)
props.setCreateUserGroupModal(false)
} else {
setIsSubmitting(false)
}
}
return (
<FormLayout onSubmit={handleSubmit}>
<FormField name="name">
<Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}>
<FormLabel>Name</FormLabel>
<FormMessage match="valueMissing">
Please provide a ug name
</FormMessage>
</Flex>
<Form.Control asChild>
<Input onChange={handleNameChange} type="text" required />
</Form.Control>
</FormField>
<FormField name="description">
<Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}>
<FormLabel>Description</FormLabel>
<FormMessage match="valueMissing">
Please provide a ug description
</FormMessage>
</Flex>
<Form.Control asChild>
<Input onChange={handleDescriptionChange} type="text" required />
</Form.Control>
</FormField>
<Flex css={{ marginTop: 25, justifyContent: 'flex-end' }}>
<Form.Submit asChild>
<ButtonBlack type="submit" css={{ marginTop: 10 }}>
{isSubmitting ? (
<BarLoader
cssOverride={{ borderRadius: 60 }}
width={60}
color="#ffffff"
/>
) : (
'Create UserGroup'
)}
</ButtonBlack>
</Form.Submit>
</Flex>
</FormLayout>
)
}
export default AddUserGroup

View file

@ -0,0 +1,115 @@
import { useOrg } from '@components/Contexts/OrgContext'
import { getAPIUrl } from '@services/config/config'
import { linkUserToUserGroup, unLinkUserToUserGroup } from '@services/usergroups/usergroups'
import { swrFetcher } from '@services/utils/ts/requests'
import { Check, Plus, X } from 'lucide-react'
import React from 'react'
import toast from 'react-hot-toast'
import useSWR, { mutate } from 'swr'
type ManageUsersProps = {
usergroup_id: any
}
function ManageUsers(props: ManageUsersProps) {
const org = useOrg() as any
const { data: OrgUsers } = useSWR(
org ? `${getAPIUrl()}orgs/${org.id}/users` : null,
swrFetcher
)
const { data: UGusers } = useSWR(
org ? `${getAPIUrl()}usergroups/${props.usergroup_id}/users` : null,
swrFetcher
)
const isUserPartOfGroup = (user_id: any) => {
if (UGusers) {
return UGusers.some((user: any) => user.id === user_id)
}
return false
}
const handleLinkUser = async (user_id: any) => {
const res = await linkUserToUserGroup(props.usergroup_id, user_id)
if (res.status === 200) {
toast.success('User linked successfully')
mutate(`${getAPIUrl()}usergroups/${props.usergroup_id}/users`)
} else {
toast.error('Error ' + res.status + ': ' + res.data.detail)
}
}
const handleUnlinkUser = async (user_id: any) => {
const res = await unLinkUserToUserGroup(props.usergroup_id, user_id)
if (res.status === 200) {
toast.success('User unlinked successfully')
mutate(`${getAPIUrl()}usergroups/${props.usergroup_id}/users`)
} else {
toast.error('Error ' + res.status + ': ' + res.data.detail)
}
}
return (
<div className='py-3'>
<table className="table-auto w-full text-left whitespace-nowrap rounded-md overflow-hidden">
<thead className="bg-gray-100 text-gray-500 rounded-xl uppercase">
<tr className="font-bolder text-sm">
<th className="py-3 px-4">User</th>
<th className="py-3 px-4">Linked</th>
<th className="py-3 px-4">Actions</th>
</tr>
</thead>
<>
<tbody className="mt-5 bg-white rounded-md">
{OrgUsers?.map((user: any) => (
<tr
key={user.user.id}
className="border-b border-gray-200 border-dashed text-sm"
>
<td className="py-3 px-4 flex space-x-2 items-center">
<span>
{user.user.first_name + ' ' + user.user.last_name}
</span>
<span className="text-xs bg-neutral-100 p-1 px-2 rounded-full text-neutral-400 font-semibold">
@{user.user.username}
</span>
</td>
<td className="py-3 px-4">
{isUserPartOfGroup(user.user.id) ?
<div className="space-x-1 flex w-fit px-4 py-1 bg-cyan-100 rounded-full items-center text-cyan-800">
<Check size={16} />
<span>Linked</span>
</div>
:
<div className="space-x-1 flex w-fit px-4 py-1 bg-gray-100 rounded-full items-center text-gray-800">
<X size={16} />
<span>Not linked</span>
</div>
}
</td>
<td className="py-3 px-4 flex space-x-2 items-end">
<button
onClick={() => handleLinkUser(user.user.id)}
className="flex space-x-2 hover:cursor-pointer p-1 px-3 bg-cyan-700 rounded-md font-bold items-center text-sm text-cyan-100">
<Plus className="w-4 h-4" />
<span> Link</span>
</button>
<button
onClick={() => handleUnlinkUser(user.user.id)}
className="flex space-x-2 hover:cursor-pointer p-1 px-3 bg-gray-700 rounded-md font-bold items-center text-sm text-gray-100">
<X className="w-4 h-4" />
<span> Unlink</span>
</button>
</td>
</tr>
))}
</tbody>
</>
</table>
</div>
)
}
export default ManageUsers

View file

@ -15,7 +15,7 @@ export const FormLabelAndMessage = (props: {
message?: string message?: string
}) => ( }) => (
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<FormLabel className="grow">{props.label}</FormLabel> <FormLabel className="grow text-sm">{props.label}</FormLabel>
{(props.message && ( {(props.message && (
<div className="text-red-700 text-sm items-center rounded-md flex space-x-1"> <div className="text-red-700 text-sm items-center rounded-md flex space-x-1">
<Info size={10} /> <Info size={10} />
@ -35,7 +35,6 @@ export const FormField = styled(Form.Field, {
}) })
export const FormLabel = styled(Form.Label, { export const FormLabel = styled(Form.Label, {
fontSize: 15,
fontWeight: 500, fontWeight: 500,
lineHeight: '35px', lineHeight: '35px',
color: 'black', color: 'black',

View file

@ -15,6 +15,7 @@ type ModalParams = {
onOpenChange: any onOpenChange: any
isDialogOpen?: boolean isDialogOpen?: boolean
minHeight?: 'sm' | 'md' | 'lg' | 'xl' | 'no-min' minHeight?: 'sm' | 'md' | 'lg' | 'xl' | 'no-min'
minWidth?: 'sm' | 'md' | 'lg' | 'xl' | 'no-min'
} }
const Modal = (params: ModalParams) => ( const Modal = (params: ModalParams) => (
@ -28,6 +29,7 @@ const Modal = (params: ModalParams) => (
<DialogContent <DialogContent
className="overflow-auto scrollbar-w-2 scrollbar-h-2 scrollbar scrollbar-thumb-black/20 scrollbar-thumb-rounded-full scrollbar-track-rounded-full" className="overflow-auto scrollbar-w-2 scrollbar-h-2 scrollbar scrollbar-thumb-black/20 scrollbar-thumb-rounded-full scrollbar-track-rounded-full"
minHeight={params.minHeight} minHeight={params.minHeight}
minWidth={params.minWidth}
> >
<DialogTopBar className="-space-y-1"> <DialogTopBar className="-space-y-1">
<DialogTitle>{params.dialogTitle}</DialogTitle> <DialogTitle>{params.dialogTitle}</DialogTitle>
@ -103,6 +105,23 @@ const DialogContent = styled(Dialog.Content, {
minHeight: '900px', minHeight: '900px',
}, },
}, },
minWidth: {
'no-min': {
minWidth: '0px',
},
sm: {
minWidth: '600px',
},
md: {
minWidth: '800px',
},
lg: {
minWidth: '1000px',
},
xl: {
minWidth: '1200px',
},
},
}, },
backgroundColor: 'white', backgroundColor: 'white',

View file

@ -10,6 +10,15 @@ export async function createInviteCode(org_id: any) {
return res return res
} }
export async function createInviteCodeWithUserGroup(org_id: any, usergroup_id: number) {
const result = await fetch(
`${getAPIUrl()}orgs/${org_id}/invites_with_usergroups?usergroup_id=${usergroup_id}`,
RequestBody('POST', null, null)
)
const res = await getResponseMetadata(result)
return res
}
export async function deleteInviteCode( export async function deleteInviteCode(
org_id: any, org_id: any,
org_invite_code_uuid: string org_invite_code_uuid: string

View file

@ -0,0 +1,71 @@
import { getAPIUrl } from '@services/config/config'
import { RequestBody, getResponseMetadata } from '@services/utils/ts/requests'
export async function getUserGroups(org_id: any) {
const result: any = await fetch(
`${getAPIUrl()}usergroups/org/${org_id}`,
RequestBody('GET', null, null)
)
const res = await getResponseMetadata(result)
return res
}
export async function createUserGroup(body: any) {
const result: any = await fetch(
`${getAPIUrl()}usergroups`,
RequestBody('POST', body, null)
)
const res = await getResponseMetadata(result)
return res
}
export async function linkUserToUserGroup(usergroup_id: any, user_id: any) {
const result: any = await fetch(
`${getAPIUrl()}usergroups/${usergroup_id}/add_users?user_ids=${user_id}`,
RequestBody('POST', null, null)
)
const res = await getResponseMetadata(result)
return res
}
export async function unLinkUserToUserGroup(usergroup_id: any, user_id: any) {
const result: any = await fetch(
`${getAPIUrl()}usergroups/${usergroup_id}/remove_users?user_ids=${user_id}`,
RequestBody('DELETE', null, null)
)
const res = await getResponseMetadata(result)
return res
}
export async function deleteUserGroup(usergroup_id: number) {
const result: any = await fetch(
`${getAPIUrl()}usergroups/${usergroup_id}`,
RequestBody('DELETE', null, null)
)
const res = await getResponseMetadata(result)
return res
}
export async function linkResourcesToUserGroup(
usergroup_id: any,
resource_uuids: any
) {
const result: any = await fetch(
`${getAPIUrl()}usergroups/${usergroup_id}/add_resources?resource_uuids=${resource_uuids}`,
RequestBody('POST', null, null)
)
const res = await getResponseMetadata(result)
return res
}
export async function unLinkResourcesToUserGroup(
usergroup_id: any,
resource_uuids: any
) {
const result: any = await fetch(
`${getAPIUrl()}usergroups/${usergroup_id}/remove_resources?resource_uuids=${resource_uuids}`,
RequestBody('DELETE', null, null)
)
const res = await getResponseMetadata(result)
return res
}