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):
courses: Permission
users: Permission
usergroups : Permission
collections: Permission
organizations: 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 src.routers import usergroups
from src.routers import blocks, dev, trail, users, auth, orgs, roles
from src.routers.ai import ai
from src.routers.courses import chapters, collections, courses, activities
@ -12,6 +13,7 @@ v1_router = APIRouter(prefix="/api/v1")
# API Routes
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(orgs.router, prefix="/orgs", tags=["orgs"])
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 src.services.orgs.invites import (
create_invite_code,
create_invite_code_with_usergroup,
delete_invite_code,
get_invite_code,
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)
@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")
async def api_get_invite_codes(
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
async def authorization_verify_based_on_roles_and_authorship(
async def authorization_verify_based_on_roles_and_authorship_and_usergroups(
request: Request,
user_id: int,
action: Literal["read", "update", "delete", "create"],

View file

@ -1,25 +1,27 @@
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
"""
if element_id.startswith("course_"):
if element_uuid.startswith("course_"):
return "courses"
elif element_id.startswith("user_"):
elif element_uuid.startswith("user_"):
return "users"
elif element_id.startswith("house_"):
elif element_uuid.startswith("usergroup_"):
return "usergroups"
elif element_uuid.startswith("house_"):
return "houses"
elif element_id.startswith("org_"):
elif element_uuid.startswith("org_"):
return "organizations"
elif element_id.startswith("chapter_"):
elif element_uuid.startswith("chapter_"):
return "coursechapters"
elif element_id.startswith("collection_"):
elif element_uuid.startswith("collection_"):
return "collections"
elif element_id.startswith("activity_"):
elif element_uuid.startswith("activity_"):
return "activities"
elif element_id.startswith("role_"):
elif element_uuid.startswith("role_"):
return "roles"
else:
raise HTTPException(
@ -28,8 +30,8 @@ async def check_element_type(element_id):
)
async def get_singular_form_of_element(element_id):
element_type = await check_element_type(element_id)
async def get_singular_form_of_element(element_uuid):
element_type = await check_element_type(element_uuid)
if element_type == "activities":
return "activity"
@ -38,8 +40,8 @@ async def get_singular_form_of_element(element_id):
return singular_form_element
async def get_id_identifier_of_element(element_id):
singular_form_element = await get_singular_form_of_element(element_id)
async def get_id_identifier_of_element(element_uuid):
singular_form_element = await get_singular_form_of_element(element_uuid)
if singular_form_element == "ogranizations":
return "org_id"

View file

@ -3,7 +3,7 @@ from sqlmodel import Session, select
from src.db.courses import Course
from src.db.chapters import Chapter
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_user_is_anon,
)
@ -238,14 +238,14 @@ async def rbac_check(
)
return res
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
)
return res
else:
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,

View file

@ -3,7 +3,7 @@ from src.db.courses import Course
from src.db.organizations import Organization
from sqlmodel import Session, select
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,
)
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_based_on_roles_and_authorship(
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
request,
current_user.id,
action,

View file

@ -5,7 +5,7 @@ from src.db.organizations import Organization
from pydantic import BaseModel
from sqlmodel import Session, select
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,
)
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_based_on_roles_and_authorship(
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
request,
current_user.id,
action,

View file

@ -4,7 +4,7 @@ from uuid import uuid4
from sqlmodel import Session, select
from src.db.users import AnonymousUser
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_user_is_anon,
)
@ -559,17 +559,16 @@ async def rbac_check(
res = await authorization_verify_if_element_is_public(
request, course_uuid, action, db_session
)
print("res", res)
return res
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
)
return res
else:
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,

View file

@ -4,7 +4,7 @@ from uuid import uuid4
from sqlmodel import Session, select
from src.db.users import AnonymousUser
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_user_is_anon,
)
@ -297,14 +297,14 @@ async def rbac_check(
detail="User rights : You are not allowed to read this collection",
)
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
)
return res
else:
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,

View file

@ -1,9 +1,11 @@
from typing import Literal
from uuid import uuid4
from sqlalchemy import union
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.trails import TrailRead
from src.services.trail.trail import get_user_trail_with_orgid
from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum
from src.db.users import PublicUser, AnonymousUser, User, UserRead
@ -15,7 +17,7 @@ from src.db.courses import (
FullCourseReadWithTrail,
)
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_user_is_anon,
)
@ -106,7 +108,6 @@ async def get_course_meta(
)
trail = TrailRead.from_orm(trail)
return FullCourseReadWithTrail(
**course.dict(),
chapters=chapters,
@ -142,7 +143,7 @@ async def create_course(
if thumbnail_file and thumbnail_file.filename:
name_in_disk = f"{course.course_uuid}_thumbnail_{uuid4()}.{thumbnail_file.filename.split('.')[-1]}"
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
@ -213,7 +214,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, org.org_uuid, course.course_uuid
thumbnail_file, name_in_disk, org.org_uuid, course.course_uuid # type: ignore
)
# Update course
@ -327,26 +328,47 @@ async def get_courses_orgslug(
page: int = 1,
limit: int = 10,
):
# Query for public courses
statement_public = (
select(Course)
.join(Organization)
.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:
statement = statement_public
else:
# RBAC check
await authorization_verify_if_user_is_anon(current_user.id)
# Query for courses where the current user is in a user group that has access to the course
statement_usergroup = (
select(Course)
.join(Organization)
.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 course in courses:
@ -367,6 +389,7 @@ async def get_courses_orgslug(
## 🔒 RBAC Utils ##
async def rbac_check(
request: Request,
course_uuid: str,
@ -381,14 +404,16 @@ async def rbac_check(
)
return res
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
)
)
return res
else:
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,

View file

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

View file

@ -94,6 +94,85 @@ async def create_invite_code(
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(
request: Request,
org_id: int,
@ -136,9 +215,11 @@ async def get_invite_codes(
# Get invite codes
invite_codes = r.keys(f"org_invite_code_*:org:{org.org_uuid}:code:*")
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 = json.loads(invite_code) # type: ignore
invite_codes_list.append(invite_code)

View file

@ -2,7 +2,7 @@ from typing import Literal
from uuid import uuid4
from sqlmodel import Session, select
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,
)
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_based_on_roles_and_authorship(
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
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 fastapi import HTTPException, Request, UploadFile, status
from sqlmodel import Session, select
from src.services.users.usergroups import add_users_to_usergroup
from src.services.users.emails import (
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.db.roles import Role, RoleRead
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,
)
from src.db.organizations import Organization, OrganizationRead
@ -124,16 +125,27 @@ async def create_user_with_invite(
):
# Check if invite code exists
isInviteCodeCorrect = await get_invite_code(
inviteCode = await get_invite_code(
request, org_id, invite_code, current_user, db_session
)
if not isInviteCodeCorrect:
if not inviteCode:
raise HTTPException(
status_code=400,
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)
return user
@ -346,6 +358,7 @@ async def update_user_password(
return user
async def read_user_by_id(
request: Request,
db_session: Session,
@ -449,7 +462,7 @@ async def authorize_user_action(
request: Request,
db_session: Session,
current_user: PublicUser | AnonymousUser,
ressource_uuid: str,
resource_uuid: str,
action: Literal["create", "read", "update", "delete"],
):
# Get user
@ -463,8 +476,10 @@ async def authorize_user_action(
)
# RBAC check
authorized = await authorization_verify_based_on_roles_and_authorship(
request, current_user.id, action, ressource_uuid, db_session
authorized = (
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
request, current_user.id, action, resource_uuid, db_session
)
)
if authorized:
@ -535,7 +550,7 @@ async def rbac_check(
if current_user.id == 0: # if user is anonymous
return True
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
)
@ -546,7 +561,7 @@ async def rbac_check(
if current_user.user_uuid == user_uuid:
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
)

View file

@ -7,7 +7,8 @@ import Link from 'next/link'
import { CourseOverviewTop } from '@components/Dashboard/UI/CourseOverviewTop'
import { motion } from 'framer-motion'
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 = {
orgslug: string
@ -24,9 +25,9 @@ function CourseOverviewPage({ params }: { params: CourseOverviewParams }) {
return (
<div className="h-screen w-full bg-[#f8f8f8] grid grid-rows-[auto,1fr]">
<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} />
<div className="flex space-x-5 font-black text-sm">
<div className="flex space-x-3 font-black text-sm">
<Link
href={
getUriWithOrg(params.orgslug, '') +
@ -34,8 +35,7 @@ function CourseOverviewPage({ params }: { params: CourseOverviewParams }) {
}
>
<div
className={`py-2 w-fit text-center border-black transition-all ease-linear ${
params.subpage.toString() === 'general'
className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'general'
? 'border-b-4'
: 'opacity-50'
} cursor-pointer`}
@ -46,6 +46,24 @@ function CourseOverviewPage({ params }: { params: CourseOverviewParams }) {
</div>
</div>
</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
href={
getUriWithOrg(params.orgslug, '') +
@ -53,8 +71,7 @@ function CourseOverviewPage({ params }: { params: CourseOverviewParams }) {
}
>
<div
className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${
params.subpage.toString() === 'content'
className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'content'
? 'border-b-4'
: 'opacity-50'
} cursor-pointer`}
@ -65,7 +82,9 @@ function CourseOverviewPage({ params }: { params: CourseOverviewParams }) {
</div>
</div>
</Link>
</div>
</div>
<motion.div
initial={{ opacity: 0 }}
@ -74,16 +93,9 @@ function CourseOverviewPage({ params }: { params: CourseOverviewParams }) {
transition={{ duration: 0.1, type: 'spring', stiffness: 80 }}
className="h-full overflow-y-auto"
>
{params.subpage == 'content' ? (
<EditCourseStructure orgslug={params.orgslug} />
) : (
''
)}
{params.subpage == 'general' ? (
<EditCourseGeneral orgslug={params.orgslug} />
) : (
''
)}
{params.subpage == 'content' ? (<EditCourseStructure orgslug={params.orgslug} />) : ('')}
{params.subpage == 'general' ? (<EditCourseGeneral orgslug={params.orgslug} />) : ('')}
{params.subpage == 'access' ? (<EditCourseAccess orgslug={params.orgslug} />) : ('')}
</motion.div>
</CourseProvider>
</div>

View file

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

View file

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

View file

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

View file

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

View file

@ -6,7 +6,7 @@ import Link from 'next/link'
import { getUriWithOrg } from '@services/config/config'
import { useSession } from '@components/Contexts/SessionContext'
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 UserAvatar from '@components/Objects/UserAvatar'
import OpenSignUpComponent from './OpenSignup'
@ -201,7 +201,7 @@ const NoTokenScreen = (props: any) => {
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"
>
<Shield size={18} />
<Ticket size={18} />
<p>Submit </p>
</button>
</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}
/>
<Form.Control asChild>
<Textarea
<Input
style={{ backgroundColor: 'white' }}
onChange={formik.handleChange}
value={formik.values.description}
type='text'
required
/>
</Form.Control>
@ -177,26 +178,7 @@ function EditCourseGeneral(props: EditCourseStructureProps) {
</Form.Control>
</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>
</div>
)}

View file

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

View file

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

View file

@ -27,7 +27,7 @@ export function CourseOverviewTop({
last_breadcrumb={course.courseStructure.name}
></BreadCrumbs>
<div className="flex">
<div className="flex py-5 grow items-center">
<div className="flex py-3 grow items-center">
<Link
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 { getAPIUrl, getUriWithOrg } from '@services/config/config'
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 React, { useEffect } from 'react'
import useSWR, { mutate } from 'swr'
@ -16,6 +16,8 @@ import {
import Toast from '@components/StyledElements/Toast/Toast'
import toast from 'react-hot-toast'
import { useRouter } from 'next/navigation'
import Modal from '@components/StyledElements/Modal/Modal'
import OrgInviteCodeGenerate from '@components/Objects/Modals/Dash/OrgAccess/OrgInviteCodeGenerate'
function OrgAccess() {
const org = useOrg() as any
@ -25,6 +27,7 @@ function OrgAccess() {
)
const [isLoading, setIsLoading] = React.useState(false)
const [joinMethod, setJoinMethod] = React.useState('closed')
const [invitesModal, setInvitesModal] = React.useState(false)
const router = useRouter()
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) {
let res = await deleteInviteCode(org.id, invite.invite_code_uuid)
@ -74,7 +70,6 @@ function OrgAccess() {
return (
<>
<Toast></Toast>
{!isLoading ? (
<>
<div className="h-6"></div>
@ -126,7 +121,7 @@ function OrgAccess() {
</div>
) : null}
<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">
Closed
</div>
@ -162,6 +157,7 @@ function OrgAccess() {
<tr className="font-bolder text-sm">
<th className="py-3 px-4">Code</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">Actions</th>
</tr>
@ -189,6 +185,19 @@ function OrgAccess() {
)}
</Link>
</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">
{dayjs(invite.expiration_date)
.add(1, 'year')
@ -216,13 +225,36 @@ function OrgAccess() {
</tbody>
</>
</table>
<div className='flex flex-row-reverse mt-3 mr-2'><button
onClick={() => createInvite()}
<div className='flex flex-row-reverse mt-3 mr-2'>
<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"
>
<Shield className="w-4 h-4" />
<Ticket className="w-4 h-4" />
<span> Generate invite code</span>
</button></div>
</button>
}
/>
</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
}) => (
<div className="flex items-center space-x-3">
<FormLabel className="grow">{props.label}</FormLabel>
<FormLabel className="grow text-sm">{props.label}</FormLabel>
{(props.message && (
<div className="text-red-700 text-sm items-center rounded-md flex space-x-1">
<Info size={10} />
@ -35,7 +35,6 @@ export const FormField = styled(Form.Field, {
})
export const FormLabel = styled(Form.Label, {
fontSize: 15,
fontWeight: 500,
lineHeight: '35px',
color: 'black',

View file

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

View file

@ -10,6 +10,15 @@ export async function createInviteCode(org_id: any) {
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(
org_id: any,
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
}