feat: scope invite code to UserGroups

This commit is contained in:
swve 2024-03-30 23:05:27 +00:00
parent e173a32e3c
commit ae677bc133
8 changed files with 212 additions and 54 deletions

View file

@ -3,6 +3,7 @@ from typing import Literal
from uuid import uuid4 from uuid import uuid4
from fastapi import HTTPException, Request, UploadFile, status from fastapi import HTTPException, Request, UploadFile, status
from sqlmodel import Session, select from sqlmodel import Session, select
from src.services.users.usergroups import add_users_to_usergroup
from src.services.users.emails import ( from src.services.users.emails import (
send_account_creation_email, send_account_creation_email,
) )
@ -124,20 +125,27 @@ async def create_user_with_invite(
): ):
# Check if invite code exists # Check if invite code exists
inviteCOde = await get_invite_code( inviteCode = await get_invite_code(
request, org_id, invite_code, current_user, db_session request, org_id, invite_code, current_user, db_session
) )
# Check if invite code contains UserGroup if not inviteCode:
#TODO
if not inviteCOde:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="Invite code is incorrect", detail="Invite code is incorrect",
) )
# Check if invite code contains UserGroup
if inviteCode.usergroup_id:
# Add user to UserGroup
await add_users_to_usergroup(
request,
db_session,
current_user,
inviteCode.usergroup_id,
user_object.username,
)
user = await create_user(request, db_session, current_user, user_object, org_id) user = await create_user(request, db_session, current_user, user_object, org_id)
return user return user
@ -350,6 +358,7 @@ async def update_user_password(
return user return user
async def read_user_by_id( async def read_user_by_id(
request: Request, request: Request,
db_session: Session, db_session: Session,
@ -467,8 +476,10 @@ async def authorize_user_action(
) )
# RBAC check # RBAC check
authorized = await authorization_verify_based_on_roles_and_authorship_and_usergroups( authorized = (
request, current_user.id, action, resource_uuid, db_session await authorization_verify_based_on_roles_and_authorship_and_usergroups(
request, current_user.id, action, resource_uuid, db_session
)
) )
if authorized: if authorized:

View file

@ -29,12 +29,12 @@ function UsersSettingsPage({ params }: { params: SettingsParams }) {
setH2Label('Manage your organization users, assign roles and permissions') setH2Label('Manage your organization users, assign roles and permissions')
} }
if (params.subpage == 'signups') { if (params.subpage == 'signups') {
setH1Label('Signup Access') setH1Label('Signups & Invite Codes')
setH2Label('Choose from where users can join your organization') setH2Label('Choose from where users can join your organization')
} }
if (params.subpage == 'add') { if (params.subpage == 'add') {
setH1Label('Invite users') setH1Label('Invite Members')
setH2Label('Invite users to join your organization') setH2Label('Invite members to join your organization')
} }
if (params.subpage == 'usergroups') { if (params.subpage == 'usergroups') {
setH1Label('UserGroups') setH1Label('UserGroups')
@ -95,23 +95,6 @@ function UsersSettingsPage({ params }: { params: SettingsParams }) {
</div> </div>
</div> </div>
</Link> </Link>
<Link
href={
getUriWithOrg(params.orgslug, '') + `/dash/users/settings/add`
}
>
<div
className={`py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'add'
? 'border-b-4'
: 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2">
<UserPlus size={16} />
<div>Invite users</div>
</div>
</div>
</Link>
<Link <Link
href={ href={
getUriWithOrg(params.orgslug, '') + `/dash/users/settings/signups` getUriWithOrg(params.orgslug, '') + `/dash/users/settings/signups`
@ -125,10 +108,28 @@ function UsersSettingsPage({ params }: { params: SettingsParams }) {
> >
<div className="flex items-center space-x-2.5 mx-2"> <div className="flex items-center space-x-2.5 mx-2">
<ScanEye size={16} /> <ScanEye size={16} />
<div>Signup Access</div> <div>Signups & Invite Codes</div>
</div> </div>
</div> </div>
</Link> </Link>
<Link
href={
getUriWithOrg(params.orgslug, '') + `/dash/users/settings/add`
}
>
<div
className={`py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'add'
? 'border-b-4'
: 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2">
<UserPlus size={16} />
<div>Invite Members</div>
</div>
</div>
</Link>
</div> </div>
</div> </div>
<motion.div <motion.div

View file

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

View file

@ -109,7 +109,7 @@ function EditCourseAccess(props: EditCourseAccessProps) {
status="info" status="info"
></ConfirmationModal> ></ConfirmationModal>
</div> </div>
<UserGroupsSection usergroups={usergroups} /> {!isPublic ? ( <UserGroupsSection usergroups={usergroups} />) : null}
</div> </div>
</div> </div>
) )
@ -137,7 +137,7 @@ function UserGroupsSection({ usergroups }: { usergroups: any[] }) {
<h1 className="font-bold text-xl text-gray-800">UserGroups</h1> <h1 className="font-bold text-xl text-gray-800">UserGroups</h1>
<h2 className="text-gray-500 text-sm"> <h2 className="text-gray-500 text-sm">
{' '} {' '}
Choose which UserGroups can access this course{' '} You can choose to give access to this course to specific groups of users only by linking it to a UserGroup{' '}
</h2> </h2>
</div> </div>
<table className="table-auto w-full text-left whitespace-nowrap rounded-md overflow-hidden"> <table className="table-auto w-full text-left whitespace-nowrap rounded-md overflow-hidden">
@ -186,13 +186,14 @@ function UserGroupsSection({ usergroups }: { usergroups: any[] }) {
setUserGroupModal(!userGroupModal) setUserGroupModal(!userGroupModal)
} }
minHeight="no-min" minHeight="no-min"
minWidth='md'
dialogContent={ dialogContent={
<LinkToUserGroup setUserGroupModal={setUserGroupModal} /> <LinkToUserGroup setUserGroupModal={setUserGroupModal} />
} }
dialogTitle="Link Course to a UserGroup" dialogTitle="Link Course to a UserGroup"
dialogDescription={ dialogDescription={
'Choose which UserGroups can access this course' 'Choose a UserGroup to link this course to, Users from this UserGroup will have access to this course.'
} }
dialogTrigger={ dialogTrigger={
<button <button

View file

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

View file

@ -4,6 +4,7 @@ import { useOrg } from '@components/Contexts/OrgContext';
import { getAPIUrl } from '@services/config/config'; import { getAPIUrl } from '@services/config/config';
import { linkResourcesToUserGroup } from '@services/usergroups/usergroups'; import { linkResourcesToUserGroup } from '@services/usergroups/usergroups';
import { swrFetcher } from '@services/utils/ts/requests'; import { swrFetcher } from '@services/utils/ts/requests';
import { AlertTriangle, Info } from 'lucide-react';
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import useSWR, { mutate } from 'swr' import useSWR, { mutate } from 'swr'
@ -47,9 +48,14 @@ function LinkToUserGroup(props: LinkToUserGroupProps) {
, [usergroups]) , [usergroups])
return ( return (
<div className='p-4 flex-row flex justify-between items-center'> <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'>
<div className='py-3'> <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> <span className='px-3 text-gray-400 font-bold rounded-full py-1 bg-gray-100 mx-3'>UserGroup Name </span>
<select <select
onChange={(e) => setSelectedUserGroup(e.target.value)} onChange={(e) => setSelectedUserGroup(e.target.value)}
@ -65,6 +71,8 @@ function LinkToUserGroup(props: LinkToUserGroupProps) {
<button onClick={() => { handleLink() }} className='bg-green-700 text-white font-bold px-4 py-2 rounded-md shadow'>Link</button> <button onClick={() => { handleLink() }} className='bg-green-700 text-white font-bold px-4 py-2 rounded-md shadow'>Link</button>
</div> </div>
</div> </div>
</div>
) )
} }

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

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