feat: users management

This commit is contained in:
swve 2024-01-22 20:37:11 +01:00
parent a552300e15
commit 689625b0d5
22 changed files with 621 additions and 36 deletions

View file

@ -1,5 +1,8 @@
from typing import Optional from typing import Optional
from pydantic import BaseModel
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
from src.db.roles import RoleRead
from src.db.organization_config import OrganizationConfig from src.db.organization_config import OrganizationConfig
@ -32,3 +35,9 @@ class OrganizationRead(OrganizationBase):
config: Optional[OrganizationConfig | dict] config: Optional[OrganizationConfig | dict]
creation_date: str creation_date: str
update_date: str update_date: str
class OrganizationUser(BaseModel):
from src.db.users import UserRead
user: UserRead
role: RoleRead

View file

@ -1,9 +1,8 @@
from typing import Optional from typing import Optional
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
from src.db.roles import RoleRead from src.db.roles import RoleRead
from src.db.organizations import OrganizationRead
class UserBase(SQLModel): class UserBase(SQLModel):
@ -45,6 +44,7 @@ class PublicUser(UserRead):
class UserRoleWithOrg(BaseModel): class UserRoleWithOrg(BaseModel):
from src.db.organizations import OrganizationRead
role: RoleRead role: RoleRead
org: OrganizationRead org: OrganizationRead

View file

@ -8,6 +8,7 @@ from src.db.organizations import (
OrganizationCreate, OrganizationCreate,
OrganizationRead, OrganizationRead,
OrganizationUpdate, OrganizationUpdate,
OrganizationUser,
) )
from src.core.events.database import get_db_session from src.core.events.database import get_db_session
from src.security.auth import get_current_user from src.security.auth import get_current_user
@ -17,9 +18,12 @@ from src.services.orgs.orgs import (
delete_org, delete_org,
get_organization, get_organization,
get_organization_by_slug, get_organization_by_slug,
get_organization_users,
get_orgs_by_user, get_orgs_by_user,
remove_user_from_org,
update_org, update_org,
update_org_logo, update_org_logo,
update_user_role,
) )
@ -69,6 +73,52 @@ async def api_get_org(
return await get_organization(request, org_id, db_session, current_user) return await get_organization(request, org_id, db_session, current_user)
@router.get("/{org_id}/users")
async def api_get_org_users(
request: Request,
org_id: str,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
) -> list[OrganizationUser]:
"""
Get single Org by ID
"""
return await get_organization_users(request, org_id, db_session, current_user)
@router.put("/{org_id}/users/{user_id}/role/{role_uuid}")
async def api_update_user_role(
request: Request,
org_id: str,
user_id: str,
role_uuid: str,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
"""
Update user role
"""
return await update_user_role(
request, org_id, user_id, role_uuid, db_session, current_user
)
@router.delete("/{org_id}/users/{user_id}")
async def api_remove_user_from_org(
request: Request,
org_id: int,
user_id: int,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
"""
Remove user from org
"""
return await remove_user_from_org(
request, org_id, user_id, db_session, current_user
)
@router.get("/slug/{org_slug}") @router.get("/slug/{org_slug}")
async def api_get_org_by_slug( async def api_get_org_by_slug(
request: Request, request: Request,

View file

@ -16,7 +16,7 @@ async def authorization_verify_if_element_is_public(
element_uuid: str, element_uuid: str,
action: Literal["read"], action: Literal["read"],
db_session: Session, db_session: Session,
): ):
element_nature = await check_element_type(element_uuid) element_nature = await check_element_type(element_uuid)
# Verifies if the element is public # Verifies if the element is public
if element_nature == ("courses" or "collections") and action == "read": if element_nature == ("courses" or "collections") and action == "read":
@ -106,6 +106,34 @@ async def authorization_verify_based_on_roles(
return False return False
async def authorization_verify_based_on_org_admin_status(
request: Request,
user_id: int,
action: Literal["read", "update", "delete", "create"],
element_uuid: str,
db_session: Session,
):
await check_element_type(element_uuid)
# Get user roles bound to an organization and standard roles
statement = (
select(Role)
.join(UserOrganization)
.where((UserOrganization.org_id == Role.org_id) | (Role.org_id == null()))
.where(UserOrganization.user_id == user_id)
)
user_roles_in_organization_and_standard_roles = db_session.exec(statement).all()
# Find in roles list if there is a role that matches users action for this type of element
for role in user_roles_in_organization_and_standard_roles:
role = Role.from_orm(role)
if role.id == 1 or role.id == 2:
return True
else:
return False
# Tested and working # Tested and working
async def authorization_verify_based_on_roles_and_authorship( async def authorization_verify_based_on_roles_and_authorship(
request: Request, request: Request,

View file

@ -225,7 +225,7 @@ async def get_course_chapters(
chapters = [ChapterRead(**chapter.dict(), activities=[]) for chapter in chapters] chapters = [ChapterRead(**chapter.dict(), activities=[]) for chapter in chapters]
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session) await rbac_check(request, course.course_uuid, current_user, "read", db_session) # type: ignore
# Get activities for each chapter # Get activities for each chapter
for chapter in chapters: for chapter in chapters:

View file

@ -213,7 +213,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, 'users', course.course_uuid thumbnail_file, name_in_disk, org.org_uuid, course.course_uuid
) )
# Update course # Update course

View file

@ -4,6 +4,7 @@ from datetime import datetime
from typing import Literal from typing import Literal
from uuid import uuid4 from uuid import uuid4
from sqlmodel import Session, select from sqlmodel import Session, select
from src.db.roles import Role, RoleRead
from src.db.organization_config import ( from src.db.organization_config import (
AIConfig, AIConfig,
AIEnabledFeatures, AIEnabledFeatures,
@ -15,16 +16,18 @@ from src.db.organization_config import (
OrganizationConfigBase, OrganizationConfigBase,
) )
from src.security.rbac.rbac import ( from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship, authorization_verify_based_on_org_admin_status,
authorization_verify_based_on_roles,
authorization_verify_if_user_is_anon, authorization_verify_if_user_is_anon,
) )
from src.db.users import AnonymousUser, PublicUser from src.db.users import AnonymousUser, PublicUser, User, UserRead
from src.db.user_organizations import UserOrganization from src.db.user_organizations import UserOrganization
from src.db.organizations import ( from src.db.organizations import (
Organization, Organization,
OrganizationCreate, OrganizationCreate,
OrganizationRead, OrganizationRead,
OrganizationUpdate, OrganizationUpdate,
OrganizationUser,
) )
from src.services.orgs.logos import upload_org_logo from src.services.orgs.logos import upload_org_logo
from fastapi import HTTPException, UploadFile, status, Request from fastapi import HTTPException, UploadFile, status, Request
@ -66,6 +69,199 @@ async def get_organization(
return org return org
async def get_organization_users(
request: Request,
org_id: str,
db_session: Session,
current_user: PublicUser | AnonymousUser,
) -> list[OrganizationUser]:
statement = select(Organization).where(Organization.id == org_id)
result = db_session.exec(statement)
org = result.first()
if not org:
raise HTTPException(
status_code=404,
detail="Organization not found",
)
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "read", db_session)
statement = (
select(User)
.join(UserOrganization)
.join(Organization)
.where(Organization.id == org_id)
)
users = db_session.exec(statement)
users = users.all()
org_users_list = []
for user in users:
statement = select(UserOrganization).where(
UserOrganization.user_id == user.id, UserOrganization.org_id == org_id
)
result = db_session.exec(statement)
user_org = result.first()
if not user_org:
logging.error(f"User {user.id} not found")
# skip this user
continue
statement = select(Role).where(Role.id == user_org.role_id)
result = db_session.exec(statement)
role = result.first()
if not role:
logging.error(f"Role {user_org.role_id} not found")
# skip this user
continue
statement = select(User).where(User.id == user_org.user_id)
result = db_session.exec(statement)
user = result.first()
if not user:
logging.error(f"User {user_org.user_id} not found")
# skip this user
continue
user = UserRead.from_orm(user)
role = RoleRead.from_orm(role)
org_user = OrganizationUser(
user=user,
role=role,
)
org_users_list.append(org_user)
return org_users_list
async def remove_user_from_org(
request: Request,
org_id: int,
user_id: int,
db_session: Session,
current_user: PublicUser | AnonymousUser,
):
statement = select(Organization).where(Organization.id == org_id)
result = db_session.exec(statement)
org = result.first()
if not org:
raise HTTPException(
status_code=404,
detail="Organization not found",
)
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "read", db_session)
statement = select(UserOrganization).where(
UserOrganization.user_id == user_id, UserOrganization.org_id == org.id
)
result = db_session.exec(statement)
user_org = result.first()
if not user_org:
raise HTTPException(
status_code=404,
detail="User not found",
)
# Check if user is the last admin
statement = select(UserOrganization).where(
UserOrganization.org_id == org.id, UserOrganization.role_id == 1
)
result = db_session.exec(statement)
admins = result.all()
if len(admins) == 1 and admins[0].user_id == user_id:
raise HTTPException(
status_code=400,
detail="You can't remove the last admin of the organization",
)
db_session.delete(user_org)
db_session.commit()
return {"detail": "User removed from org"}
async def update_user_role(
request: Request,
org_id: str,
user_id: str,
role_uuid: str,
db_session: Session,
current_user: PublicUser | AnonymousUser,
):
# find role
statement = select(Role).where(Role.role_uuid == role_uuid)
result = db_session.exec(statement)
role = result.first()
if not role:
raise HTTPException(
status_code=404,
detail="Role not found",
)
role_id = role.id
statement = select(Organization).where(Organization.id == org_id)
result = db_session.exec(statement)
org = result.first()
if not org:
raise HTTPException(
status_code=404,
detail="Organization not found",
)
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "read", db_session)
statement = select(UserOrganization).where(
UserOrganization.user_id == user_id, UserOrganization.org_id == org.id
)
result = db_session.exec(statement)
user_org = result.first()
if not user_org:
raise HTTPException(
status_code=404,
detail="User not found",
)
if role_id is not None:
user_org.role_id = role_id
db_session.add(user_org)
db_session.commit()
db_session.refresh(user_org)
return {"detail": "User role updated"}
async def get_organization_by_slug( async def get_organization_by_slug(
request: Request, request: Request,
org_slug: str, org_slug: str,
@ -443,7 +639,7 @@ async def get_orgs_by_user(
async def rbac_check( async def rbac_check(
request: Request, request: Request,
org_id: str, org_uuid: str,
current_user: PublicUser | AnonymousUser, current_user: PublicUser | AnonymousUser,
action: Literal["create", "read", "update", "delete"], action: Literal["create", "read", "update", "delete"],
db_session: Session, db_session: Session,
@ -455,8 +651,12 @@ async def rbac_check(
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(
request, current_user.id, action, org_id, db_session request, current_user.id, action, org_uuid, db_session
)
await authorization_verify_based_on_org_admin_status(
request, current_user.id, action, org_uuid, db_session
) )

View file

@ -5,7 +5,7 @@ async def upload_avatar(avatar_file, name_in_disk, user_uuid):
contents = avatar_file.file.read() contents = avatar_file.file.read()
try: try:
await upload_content( await upload_content(
f"avatars", "avatars",
"users", "users",
user_uuid, user_uuid,
contents, contents,

View file

@ -2,7 +2,7 @@ import Image from 'next/image'
import React from 'react' import React from 'react'
import learnhousetextlogo from '../../../../public/learnhouse_logo.png' import learnhousetextlogo from '../../../../public/learnhouse_logo.png'
import learnhouseiconlogo from '../../../../public/learnhouse_bigicon.png' import learnhouseiconlogo from '../../../../public/learnhouse_bigicon.png'
import { BookCopy, School, Settings } from 'lucide-react' import { BookCopy, School, Settings, Users } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
function DashboardHome() { function DashboardHome() {
@ -12,33 +12,46 @@ function DashboardHome() {
<Image alt='learnhouse logo' width={230} src={learnhousetextlogo}></Image> <Image alt='learnhouse logo' width={230} src={learnhousetextlogo}></Image>
</div> </div>
<div className='flex space-x-10'> <div className='flex space-x-10'>
<Link href={`/dash/courses`} className='flex bg-white shadow-lg p-[35px] w-[350px] rounded-lg items-center mx-auto hover:scale-105 transition-all ease-linear cursor-pointer'> <Link href={`/dash/courses`} className='flex bg-white shadow-lg p-[35px] w-[250px] rounded-lg items-center mx-auto hover:scale-105 transition-all ease-linear cursor-pointer'>
<div className='flex flex-col mx-auto space-y-2'> <div className='flex flex-col mx-auto space-y-2'>
<BookCopy className='mx-auto text-gray-500' size={50}></BookCopy> <BookCopy className='mx-auto text-gray-500' size={50}></BookCopy>
<div className='text-center font-bold text-gray-500'>Courses</div> <div className='text-center font-bold text-gray-500'>Courses</div>
<p className='text-center text-sm text-gray-400'>Create and manage courses, chapters and ativities </p> <p className='text-center text-sm text-gray-400'>Create and manage courses, chapters and ativities </p>
</div> </div>
</Link> </Link>
<Link href={`/dash/org/settings/general`} className='flex bg-white shadow-lg p-[35px] w-[350px] rounded-lg items-center mx-auto hover:scale-105 transition-all ease-linear cursor-pointer'> <Link href={`/dash/org/settings/general`} className='flex bg-white shadow-lg p-[35px] w-[250px] rounded-lg items-center mx-auto hover:scale-105 transition-all ease-linear cursor-pointer'>
<div className='flex flex-col mx-auto space-y-2'> <div className='flex flex-col mx-auto space-y-2'>
<School className='mx-auto text-gray-500' size={50}></School> <School className='mx-auto text-gray-500' size={50}></School>
<div className='text-center font-bold text-gray-500'>Organization</div> <div className='text-center font-bold text-gray-500'>Organization</div>
<p className='text-center text-sm text-gray-400'>Configure your Organization general settings </p> <p className='text-center text-sm text-gray-400'>Configure your Organization general settings </p>
</div> </div>
</Link> </Link>
<Link href={'/dash/user/settings/general'} className='flex bg-white shadow-lg p-[35px] w-[350px] rounded-lg items-center mx-auto hover:scale-105 transition-all ease-linear cursor-pointer'> <Link href={`/dash/users/settings/users`} className='flex bg-white shadow-lg p-[35px] w-[250px] rounded-lg items-center mx-auto hover:scale-105 transition-all ease-linear cursor-pointer'>
<div className='flex flex-col mx-auto space-y-2'> <div className='flex flex-col mx-auto space-y-2'>
<Settings className='mx-auto text-gray-500' size={50}></Settings> <Users className='mx-auto text-gray-500' size={50}></Users>
<div className='text-center font-bold text-gray-500'>Personal Settings</div> <div className='text-center font-bold text-gray-500'>Users</div>
<p className='text-center text-sm text-gray-400'>Configure your personal settings, passwords, email</p> <p className='text-center text-sm text-gray-400'>Manage your Organization's users, roles </p>
</div>
</Link>
</div>
<div className='flex flex-col space-y-10 '>
<div className='h-1 w-[100px] bg-neutral-200 rounded-full mx-auto'></div>
<div className="flex justify-center items-center">
<Link href={'https://learn.learnhouse.io/'} className='flex mt-[40px] bg-black space-x-2 items-center py-3 px-7 rounded-lg shadow-lg hover:scale-105 transition-all ease-linear cursor-pointer'>
<BookCopy className=' text-gray-100' size={20}></BookCopy>
<div className=' text-sm font-bold text-gray-100'>Learn LearnHouse</div>
</Link>
</div>
<div className='mx-auto mt-[40px] w-28 h-1 bg-neutral-200 rounded-full'></div>
<Link href={'/dash/user-account/settings/general'} className='flex bg-white shadow-lg p-[15px] items-center rounded-lg items-center mx-auto hover:scale-105 transition-all ease-linear cursor-pointer'>
<div className='flex flex-row mx-auto space-x-3 items-center'>
<Settings className=' text-gray-500' size={20}></Settings>
<div className=' font-bold text-gray-500'>Account Settings</div>
<p className=' text-sm text-gray-400'>Configure your personal settings, passwords, email</p>
</div> </div>
</Link> </Link>
</div> </div>
<div className='mt-[80px] h-1 w-[100px] bg-neutral-200 rounded-full'></div>
<Link href={'https://learn.learnhouse.io/'} className='flex mt-[40px] bg-black space-x-4 items-center py-3 px-7 rounded-lg shadow-lg hover:scale-105 transition-all ease-linear cursor-pointer'>
<BookCopy className='mx-auto text-gray-100' size={20}></BookCopy>
<div className='text-center text-sm font-bold text-gray-100'>Learn LearnHouse</div>
</Link>
</div> </div>
) )
} }

View file

@ -1,8 +1,8 @@
'use client'; 'use client';
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import UserEditGeneral from '@components/Dashboard/User/UserEditGeneral/UserEditGeneral'; import UserEditGeneral from '@components/Dashboard/UserAccount/UserEditGeneral/UserEditGeneral';
import UserEditPassword from '@components/Dashboard/User/UserEditPassword/UserEditPassword'; import UserEditPassword from '@components/Dashboard/UserAccount/UserEditPassword/UserEditPassword';
import Link from 'next/link'; import Link from 'next/link';
import { getUriWithOrg } from '@services/config/config'; import { getUriWithOrg } from '@services/config/config';
import { Info, Lock } from 'lucide-react'; import { Info, Lock } from 'lucide-react';
@ -24,7 +24,7 @@ function SettingsPage({ params }: { params: SettingsParams }) {
return ( return (
<div className='h-full w-full bg-[#f8f8f8]'> <div className='h-full w-full bg-[#f8f8f8]'>
<div className='pl-10 pr-10 tracking-tight bg-[#fcfbfc] shadow-[0px_4px_16px_rgba(0,0,0,0.02)]'> <div className='pl-10 pr-10 tracking-tight bg-[#fcfbfc] z-10 shadow-[0px_4px_16px_rgba(0,0,0,0.06)]'>
<BreadCrumbs type='user' last_breadcrumb={session?.user?.username} ></BreadCrumbs> <BreadCrumbs type='user' last_breadcrumb={session?.user?.username} ></BreadCrumbs>
<div className='my-2 tracking-tighter'> <div className='my-2 tracking-tighter'>
<div className='w-100 flex justify-between'> <div className='w-100 flex justify-between'>
@ -32,7 +32,7 @@ function SettingsPage({ params }: { params: SettingsParams }) {
</div> </div>
</div> </div>
<div className='flex space-x-5 font-black text-sm'> <div className='flex space-x-5 font-black text-sm'>
<Link href={getUriWithOrg(params.orgslug, "") + `/dash/user/settings/general`}> <Link href={getUriWithOrg(params.orgslug, "") + `/dash/user-account/settings/general`}>
<div className={`py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'general' ? 'border-b-4' : 'opacity-50'} cursor-pointer`}> <div className={`py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'general' ? 'border-b-4' : 'opacity-50'} cursor-pointer`}>
<div className='flex items-center space-x-2.5 mx-2'> <div className='flex items-center space-x-2.5 mx-2'>
@ -41,7 +41,7 @@ function SettingsPage({ params }: { params: SettingsParams }) {
</div> </div>
</div> </div>
</Link> </Link>
<Link href={getUriWithOrg(params.orgslug, "") + `/dash/user/settings/security`}> <Link href={getUriWithOrg(params.orgslug, "") + `/dash/user-account/settings/security`}>
<div className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'security' ? 'border-b-4' : 'opacity-50'} cursor-pointer`}> <div className={`flex space-x-4 py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'security' ? 'border-b-4' : 'opacity-50'} cursor-pointer`}>
<div className='flex items-center space-x-2.5 mx-2'> <div className='flex items-center space-x-2.5 mx-2'>
<Lock size={16} /> <Lock size={16} />
@ -58,6 +58,7 @@ function SettingsPage({ params }: { params: SettingsParams }) {
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.10, type: "spring", stiffness: 80 }} transition={{ duration: 0.10, type: "spring", stiffness: 80 }}
className='h-full overflow-y-auto'
> >
{params.subpage == 'general' ? <UserEditGeneral /> : ''} {params.subpage == 'general' ? <UserEditGeneral /> : ''}
{params.subpage == 'security' ? <UserEditPassword /> : ''} {params.subpage == 'security' ? <UserEditPassword /> : ''}

View file

@ -0,0 +1,63 @@
'use client';
import React, { useEffect } from 'react'
import { motion } from 'framer-motion';
import UserEditGeneral from '@components/Dashboard/UserAccount/UserEditGeneral/UserEditGeneral';
import UserEditPassword from '@components/Dashboard/UserAccount/UserEditPassword/UserEditPassword';
import Link from 'next/link';
import { getUriWithOrg } from '@services/config/config';
import { Info, Lock, User, Users } from 'lucide-react';
import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs';
import { useSession } from '@components/Contexts/SessionContext';
import { useOrg } from '@components/Contexts/OrgContext';
import OrgUsers from '@components/Dashboard/Users/OrgUsers/OrgUsers';
export type SettingsParams = {
subpage: string
orgslug: string
}
function UsersSettingsPage({ params }: { params: SettingsParams }) {
const session = useSession() as any;
const org = useOrg() as any;
useEffect(() => {
}
, [session, org])
return (
<div className='h-screen w-full bg-[#f8f8f8] grid grid-rows-[auto,1fr]'>
<div className='pl-10 pr-10 tracking-tight bg-[#fcfbfc] z-10 shadow-[0px_4px_16px_rgba(0,0,0,0.06)]'>
<BreadCrumbs type='org' last_breadcrumb='User settings' ></BreadCrumbs>
<div className='my-2 tracking-tighter'>
<div className='w-100 flex justify-between'>
<div className='pt-3 flex font-bold text-4xl'>Organization Users Settings</div>
</div>
</div>
<div className='flex space-x-5 font-black text-sm'>
<Link href={getUriWithOrg(params.orgslug, "") + `/dash/users/settings/users`}>
<div className={`py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'users' ? 'border-b-4' : 'opacity-50'} cursor-pointer`}>
<div className='flex items-center space-x-2.5 mx-2'>
<Users size={16} />
<div>Users</div>
</div>
</div>
</Link>
</div>
</div>
<motion.div
initial={{ opacity: 0, }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.10, type: "spring", stiffness: 80 }}
className='h-full overflow-y-auto'
>
{params.subpage == 'users' ? <OrgUsers /> : ''}
</motion.div>
</div>
)
}
export default UsersSettingsPage

View file

@ -62,7 +62,7 @@ function ThumbnailUpdate() {
<div className='flex justify-center items-center'> <div className='flex justify-center items-center'>
<input type="file" id="fileInput" style={{ display: 'none' }} onChange={handleFileChange} /> <input type="file" id="fileInput" style={{ display: 'none' }} onChange={handleFileChange} />
<button <button
className='font-bold antialiased items-center bg-gray-200 text-gray text-sm rounded-md px-4 py-2 mt-4 flex' className='font-bold antialiased items-center text-gray text-sm rounded-md px-4 mt-6 flex'
onClick={() => document.getElementById('fileInput')?.click()} onClick={() => document.getElementById('fileInput')?.click()}
> >
<UploadCloud size={16} className='mr-2' /> <UploadCloud size={16} className='mr-2' />

View file

@ -17,7 +17,7 @@ function BreadCrumbs(props: BreadCrumbsProps) {
<div className='text-gray-400 tracking-tight font-medium text-sm flex space-x-1'> <div className='text-gray-400 tracking-tight font-medium text-sm flex space-x-1'>
<div className='flex items-center space-x-1'> <div className='flex items-center space-x-1'>
{props.type == 'courses' ? <div className='flex space-x-2 items-center'> <Book className='text-gray' size={14}></Book><Link href='/dash/courses'>Courses</Link></div> : ''} {props.type == 'courses' ? <div className='flex space-x-2 items-center'> <Book className='text-gray' size={14}></Book><Link href='/dash/courses'>Courses</Link></div> : ''}
{props.type == 'user' ? <div className='flex space-x-2 items-center'> <User className='text-gray' size={14}></User><Link href='/dash/user/settings/general'>Account Settings</Link></div> : ''} {props.type == 'user' ? <div className='flex space-x-2 items-center'> <User className='text-gray' size={14}></User><Link href='/dash/user-account/settings/general'>Account Settings</Link></div> : ''}
{props.type == 'org' ? <div className='flex space-x-2 items-center'> <School className='text-gray' size={14}></School><Link href='/dash/users'>Organization Settings</Link></div> : ''} {props.type == 'org' ? <div className='flex space-x-2 items-center'> <School className='text-gray' size={14}></School><Link href='/dash/users'>Organization Settings</Link></div> : ''}
<div className='flex items-center space-x-1 first-letter:uppercase'> <div className='flex items-center space-x-1 first-letter:uppercase'>
{props.last_breadcrumb ? <ChevronRight size={17} /> : ''} {props.last_breadcrumb ? <ChevronRight size={17} /> : ''}

View file

@ -4,7 +4,7 @@ import { useSession } from '@components/Contexts/SessionContext';
import ToolTip from '@components/StyledElements/Tooltip/Tooltip' import ToolTip from '@components/StyledElements/Tooltip/Tooltip'
import LearnHouseDashboardLogo from '@public/dashLogo.png'; import LearnHouseDashboardLogo from '@public/dashLogo.png';
import { logout } from '@services/auth/auth'; import { logout } from '@services/auth/auth';
import { ArrowLeft, Book, BookCopy, Home, LogOut, School, Settings } from 'lucide-react' import { ArrowLeft, Book, BookCopy, Home, LogOut, School, Settings, Users } from 'lucide-react'
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@ -65,6 +65,9 @@ function LeftMenu() {
<ToolTip content={"Courses"} slateBlack sideOffset={8} side='right' > <ToolTip content={"Courses"} slateBlack sideOffset={8} side='right' >
<Link className='bg-white/5 rounded-lg p-2 hover:bg-white/10 transition-all ease-linear' href={`/dash/courses`} ><BookCopy size={18} /></Link> <Link className='bg-white/5 rounded-lg p-2 hover:bg-white/10 transition-all ease-linear' href={`/dash/courses`} ><BookCopy size={18} /></Link>
</ToolTip> </ToolTip>
<ToolTip content={"Users"} slateBlack sideOffset={8} side='right' >
<Link className='bg-white/5 rounded-lg p-2 hover:bg-white/10 transition-all ease-linear' href={`/dash/users/settings/users`} ><Users size={18} /></Link>
</ToolTip>
<ToolTip content={"Organization"} slateBlack sideOffset={8} side='right' > <ToolTip content={"Organization"} slateBlack sideOffset={8} side='right' >
<Link className='bg-white/5 rounded-lg p-2 hover:bg-white/10 transition-all ease-linear' href={`/dash/org/settings/general`} ><School size={18} /></Link> <Link className='bg-white/5 rounded-lg p-2 hover:bg-white/10 transition-all ease-linear' href={`/dash/org/settings/general`} ><School size={18} /></Link>
</ToolTip> </ToolTip>
@ -79,7 +82,7 @@ function LeftMenu() {
</ToolTip> </ToolTip>
<div className='flex items-center flex-col space-y-1'> <div className='flex items-center flex-col space-y-1'>
<ToolTip content={session.user.username + "'s Settings"} slateBlack sideOffset={8} side='right' > <ToolTip content={session.user.username + "'s Settings"} slateBlack sideOffset={8} side='right' >
<Link href={'/dash/user/settings/general'} className='py-3'> <Link href={'/dash/user-account/settings/general'} className='py-3'>
<Settings className='mx-auto text-neutral-400 cursor-pointer' size={18} /> <Settings className='mx-auto text-neutral-400 cursor-pointer' size={18} />
</Link> </Link>
</ToolTip> </ToolTip>

View file

@ -0,0 +1,116 @@
import { useOrg } from '@components/Contexts/OrgContext';
import PageLoading from '@components/Objects/Loaders/PageLoading';
import RolesUpdate from '@components/Objects/Modals/Dash/OrgUsers/RolesUpdate';
import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal';
import Modal from '@components/StyledElements/Modal/Modal';
import Toast from '@components/StyledElements/Toast/Toast';
import { getAPIUrl } from '@services/config/config';
import { removeUserFromOrg } from '@services/organizations/orgs';
import { swrFetcher } from '@services/utils/ts/requests';
import { KeyRound, LogOut, X } from 'lucide-react';
import React, { use, useEffect } from 'react'
import toast from 'react-hot-toast';
import useSWR, { mutate } from 'swr';
function OrgUsers() {
const org = useOrg() as any;
const { data: orgUsers } = useSWR(org ? `${getAPIUrl()}orgs/${org?.id}/users` : null, swrFetcher);
const [rolesModal, setRolesModal] = React.useState(false);
const [selectedUser, setSelectedUser] = React.useState(null) as any;
const [isLoading, setIsLoading] = React.useState(true);
const handleRolesModal = (user_uuid: any) => {
setSelectedUser(user_uuid);
setRolesModal(!rolesModal);
}
const handleRemoveUser = async (user_id: any) => {
const res = await removeUserFromOrg(org.id, user_id);
if (res.status === 200) {
await mutate(`${getAPIUrl()}orgs/${org.id}/users`);
}
else {
toast.error('Error ' + res.status + ': ' + res.data.detail)
}
}
useEffect(() => {
if (orgUsers) {
setIsLoading(false)
console.log(orgUsers)
}
}, [org, orgUsers])
return (
<div>
{isLoading ? <div><PageLoading /></div> :
<>
<Toast></Toast>
<div className="h-6"></div>
<div className='ml-10 mr-10 mx-auto bg-white rounded-xl shadow-sm px-4 py-4 '>
<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'>Role</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'>
<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'>{user.role.name}</td>
<td className='py-3 px-4 flex space-x-2 items-end'>
<Modal
isDialogOpen={rolesModal && selectedUser === user.user.user_uuid}
onOpenChange={() => handleRolesModal(user.user.user_uuid)}
minHeight="no-min"
dialogContent={
<RolesUpdate
alreadyAssignedRole={user.role.role_uuid}
setRolesModal={setRolesModal}
user={user} />
}
dialogTitle="Update Role"
dialogDescription={"Update @" + user.user.username + "'s role"}
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'>
<KeyRound className='w-4 h-4' />
<span> Edit Role</span>
</button>}
/>
<ConfirmationModal
confirmationButtonText='Remove User'
confirmationMessage='Are you sure you want remove this user from the organization?'
dialogTitle={'Delete ' + user.user.username + ' ?'}
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'>
<LogOut className='w-4 h-4' />
<span> Remove from organization</span>
</button>}
functionToExecute={() => { handleRemoveUser(user.user.id) }}
status='warning'
></ConfirmationModal>
</td>
</tr>
))}
</tbody>
</>
</table>
</div>
</>
}
</div>
)
}
export default OrgUsers

View file

@ -126,7 +126,7 @@ function CreateCourseModal({ closeModal, orgslug }: any) {
<FormField name="course-visibility"> <FormField name="course-visibility">
<Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}> <Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}>
<FormLabel>Course Visibility</FormLabel> <FormLabel>Course Visibility</FormLabel>
<FormMessage match="valueMissing">Please choose cours visibility</FormMessage> <FormMessage match="valueMissing">Please choose course visibility</FormMessage>
</Flex> </Flex>
<Form.Control asChild> <Form.Control asChild>
<select onChange={handleVisibilityChange} className='border border-gray-300 rounded-md p-2' required> <select onChange={handleVisibilityChange} className='border border-gray-300 rounded-md p-2' required>

View file

@ -0,0 +1,82 @@
'use client';
import { useOrg } from '@components/Contexts/OrgContext';
import FormLayout, { ButtonBlack, Flex, FormField, FormLabel, Input, Textarea } from '@components/StyledElements/Form/Form'
import * as Form from '@radix-ui/react-form'
import { FormMessage } from "@radix-ui/react-form";
import { getAPIUrl } from '@services/config/config';
import { updateUserRole } from '@services/organizations/orgs';
import { swrFetcher } from '@services/utils/ts/requests';
import React, { useEffect } from 'react'
import { BarLoader } from 'react-spinners';
import { mutate } from 'swr';
interface Props {
user: any
setRolesModal: any
alreadyAssignedRole: any
}
function RolesUpdate(props: Props) {
const org = useOrg() as any;
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [assignedRole, setAssignedRole] = React.useState(props.alreadyAssignedRole);
const [error, setError] = React.useState(null) as any;
const handleAssignedRole = (event: React.ChangeEvent<any>) => {
setError(null);
setAssignedRole(event.target.value);
}
const handleSubmit = async (e: any) => {
e.preventDefault();
setIsSubmitting(true);
const res = await updateUserRole(org.id, props.user.user.id, assignedRole);
if (res.status === 200) {
await mutate(`${getAPIUrl()}orgs/${org.id}/users`);
props.setRolesModal(false);
}
else {
setIsSubmitting(false);
setError('Error ' + res.status + ': ' + res.data.detail);
}
};
useEffect(() => {
}
, [assignedRole])
return (
<div>
<FormLayout onSubmit={handleSubmit}>
<FormField name="course-visibility">
{error ? <div className='text-red-500 font-bold text-xs px-3 py-2 bg-red-100 rounded-md'>{error}</div> : ''}
<Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}>
<FormLabel>Roles</FormLabel>
<FormMessage match="valueMissing">Please choose a role for the user</FormMessage>
</Flex>
<Form.Control asChild>
<select onChange={handleAssignedRole} defaultValue={assignedRole} className='border border-gray-300 rounded-md p-2' required>
<option value="role_global_admin">Admin </option>
<option value="role_global_maintainer">Maintainer</option>
<option value="role_global_user">User</option>
</select>
</Form.Control>
</FormField>
<div className='h-full'></div>
<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" />
: "Update user role"}
</ButtonBlack>
</Form.Submit>
</Flex>
</FormLayout>
</div>
)
}
export default RolesUpdate

View file

@ -5,7 +5,7 @@ import { blackA, violet, mauve } from '@radix-ui/colors';
import { Info } from 'lucide-react'; import { Info } from 'lucide-react';
const FormLayout = (props: any, onSubmit: any) => ( const FormLayout = (props: any, onSubmit: any) => (
<FormRoot onSubmit={props.onSubmit}> <FormRoot className='h-fit' onSubmit={props.onSubmit}>
{props.children} {props.children}
</FormRoot> </FormRoot>
); );

View file

@ -1,5 +1,5 @@
import { getAPIUrl } from "@services/config/config"; import { getAPIUrl } from "@services/config/config";
import { RequestBody, errorHandling } from "@services/utils/ts/requests"; import { RequestBody, errorHandling, getResponseMetadata } from "@services/utils/ts/requests";
/* /*
This file includes only POST, PUT, DELETE requests This file includes only POST, PUT, DELETE requests
@ -49,3 +49,15 @@ export function getOrganizationContextInfoNoAsync(org_slug: any, next: any) {
const result = fetch(`${getAPIUrl()}orgs/slug/${org_slug}`, RequestBody("GET", null, next)); const result = fetch(`${getAPIUrl()}orgs/slug/${org_slug}`, RequestBody("GET", null, next));
return result; return result;
} }
export async function updateUserRole(org_id: any, user_id: any, role_uuid: any) {
const result = await fetch(`${getAPIUrl()}orgs/${org_id}/users/${user_id}/role/${role_uuid}`, RequestBody("PUT", null, null));
const res = await getResponseMetadata(result);
return res;
}
export async function removeUserFromOrg(org_id: any, user_id: any) {
const result = await fetch(`${getAPIUrl()}orgs/${org_id}/users/${user_id}`, RequestBody("DELETE", null, null));
const res = await getResponseMetadata(result);
return res;
}

View file

@ -75,7 +75,15 @@ export const errorHandling = (res: any) => {
return res.json(); return res.json();
}; };
export const getResponseMetadata = async (fetch_result: any) => {
type CustomResponseTyping = {
success: boolean;
data: any;
status: number;
HTTPmessage: string;
};
export const getResponseMetadata = async (fetch_result: any): Promise<CustomResponseTyping> => {
const json = await fetch_result.json(); const json = await fetch_result.json();
if (fetch_result.status === 200) { if (fetch_result.status === 200) {
return { success: true, data: json, status: fetch_result.status, HTTPmessage: fetch_result.statusText }; return { success: true, data: json, status: fetch_result.status, HTTPmessage: fetch_result.statusText };