diff --git a/apps/api/src/routers/orgs.py b/apps/api/src/routers/orgs.py index 51d48f9d..72ce616e 100644 --- a/apps/api/src/routers/orgs.py +++ b/apps/api/src/routers/orgs.py @@ -1,6 +1,20 @@ -from typing import List +from typing import List, Literal from fastapi import APIRouter, Depends, Request, UploadFile from sqlmodel import Session +from src.services.orgs.invites import ( + create_invite_code, + delete_invite_code, + get_invite_code, + get_invite_codes, +) +from src.services.orgs.users import ( + get_list_of_invited_users, + get_organization_users, + invite_batch_users, + remove_invited_user, + remove_user_from_org, + update_user_role, +) from src.db.organization_config import OrganizationConfigBase from src.db.users import PublicUser from src.db.organizations import ( @@ -18,12 +32,10 @@ from src.services.orgs.orgs import ( delete_org, get_organization, get_organization_by_slug, - get_organization_users, get_orgs_by_user, - remove_user_from_org, update_org, update_org_logo, - update_user_role, + update_org_signup_mechanism, ) @@ -119,6 +131,120 @@ async def api_remove_user_from_org( ) +# Config related routes +@router.put("/{org_id}/signup_mechanism") +async def api_get_org_signup_mechanism( + request: Request, + org_id: int, + signup_mechanism: Literal["open", "inviteOnly"], + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + """ + Get org signup mechanism + """ + return await update_org_signup_mechanism( + request, signup_mechanism, org_id, current_user, db_session + ) + + +# Invites related routes +@router.post("/{org_id}/invites") +async def api_create_invite_code( + request: Request, + org_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + """ + Create invite code + """ + return await create_invite_code(request, org_id, current_user, db_session) + + +@router.get("/{org_id}/invites") +async def api_get_invite_codes( + request: Request, + org_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + """ + Get invite codes + """ + return await get_invite_codes(request, org_id, current_user, db_session) + +@router.get("/{org_id}/invites/code/{invite_code}") +async def api_get_invite_code( + request: Request, + org_id: int, + invite_code: str, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + """ + Get invite code + """ + print(f"org_id: {org_id}, invite_code: {invite_code}") + return await get_invite_code(request, org_id,invite_code, current_user, db_session) + + +@router.delete("/{org_id}/invites/{org_invite_code_uuid}") +async def api_delete_invite_code( + request: Request, + org_id: int, + org_invite_code_uuid: str, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + """ + Delete invite code + """ + return await delete_invite_code( + request, org_id, org_invite_code_uuid, current_user, db_session + ) + + +@router.post("/{org_id}/invites/users/batch") +async def api_invite_batch_users( + request: Request, + org_id: int, + users: str, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + """ + Invite batch users + """ + return await invite_batch_users(request, org_id, users, db_session, current_user) + + +@router.get("/{org_id}/invites/users") +async def api_get_org_users_invites( + request: Request, + org_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + """ + Get org users invites + """ + return await get_list_of_invited_users(request, org_id, db_session, current_user) + +@router.delete("/{org_id}/invites/users/{email}") +async def api_delete_org_users_invites( + request: Request, + org_id: int, + email: str, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + """ + Delete org users invites + """ + return await remove_invited_user(request, org_id, email, db_session, current_user) + + @router.get("/slug/{org_slug}") async def api_get_org_by_slug( request: Request, diff --git a/apps/api/src/routers/users.py b/apps/api/src/routers/users.py index dee50ca1..55365db2 100644 --- a/apps/api/src/routers/users.py +++ b/apps/api/src/routers/users.py @@ -1,6 +1,7 @@ from typing import Literal -from fastapi import APIRouter, Depends, Request, UploadFile +from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile from sqlmodel import Session +from src.services.orgs.orgs import get_org_join_mechanism from src.security.auth import get_current_user from src.core.events.database import get_db_session @@ -16,6 +17,7 @@ from src.db.users import ( from src.services.users.users import ( authorize_user_action, create_user, + create_user_with_invite, create_user_without_org, delete_user_by_id, get_user_session, @@ -78,7 +80,48 @@ async def api_create_user_with_orgid( """ Create User with Org ID """ - return await create_user(request, db_session, current_user, user_object, org_id) + print(await get_org_join_mechanism(request, org_id, current_user, db_session)) + + # TODO(fix) : This is temporary, logic should be moved to service + if ( + await get_org_join_mechanism(request, org_id, current_user, db_session) + == "inviteOnly" + ): + raise HTTPException( + status_code=403, + detail="You need an invite to join this organization", + ) + else: + return await create_user(request, db_session, current_user, user_object, org_id) + + +@router.post("/{org_id}/invite/{invite_code}", response_model=UserRead, tags=["users"]) +async def api_create_user_with_orgid_and_invite( + *, + request: Request, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), + user_object: UserCreate, + invite_code: str, + org_id: int, +) -> UserRead: + """ + Create User with Org ID and invite code + """ + + # TODO: This is temporary, logic should be moved to service + if ( + await get_org_join_mechanism(request, org_id, current_user, db_session) + == "inviteOnly" + ): + return await create_user_with_invite( + request, db_session, current_user, user_object, org_id, invite_code + ) + else: + raise HTTPException( + status_code=403, + detail="This organization does not require an invite code", + ) @router.post("/", response_model=UserRead, tags=["users"]) diff --git a/apps/api/src/services/orgs/invites.py b/apps/api/src/services/orgs/invites.py new file mode 100644 index 00000000..e66439d8 --- /dev/null +++ b/apps/api/src/services/orgs/invites.py @@ -0,0 +1,251 @@ +import json +import random +import string +import uuid +import redis +from datetime import datetime, timedelta +from sqlmodel import Session, select +from config.config import get_learnhouse_config +from src.services.orgs.orgs import rbac_check +from src.db.users import AnonymousUser, PublicUser +from src.db.organizations import ( + Organization, +) +from fastapi import HTTPException, Request + + +async def create_invite_code( + request: Request, + org_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, + "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, + 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", + ) + + # 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: + invite_code = r.get(invite_code) + invite_code = json.loads(invite_code) + invite_codes_list.append(invite_code) + + return invite_codes_list + +async def get_invite_code( + request: Request, + org_id: int, + invite_code: str, + 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", + ) + + + # Get invite code + invite_code = r.keys(f"org_invite_code_*:org:{org.org_uuid}:code:{invite_code}") # type: ignore + + if not invite_code: + raise HTTPException( + status_code=404, + detail="Invite code not found", + ) + + invite_code = r.get(invite_code[0]) # type: ignore + invite_code = json.loads(invite_code) + + return invite_code + + + +async def delete_invite_code( + request: Request, + org_id: int, + invite_code_uuid: str, + 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", + ) + + # Delete invite code + invite_code = r.delete(f"{invite_code_uuid}:org:{org.org_uuid}:code:*") + + if not invite_code: + raise HTTPException( + status_code=404, + detail="Invite code not found", + ) + + return invite_code diff --git a/apps/api/src/services/orgs/logos.py b/apps/api/src/services/orgs/logos.py index 6895f090..9bb173d3 100644 --- a/apps/api/src/services/orgs/logos.py +++ b/apps/api/src/services/orgs/logos.py @@ -9,7 +9,7 @@ async def upload_org_logo(logo_file, org_uuid): await upload_content( "logos", - org_uuid, + "orgs", org_uuid, contents, name_in_disk, diff --git a/apps/api/src/services/orgs/orgs.py b/apps/api/src/services/orgs/orgs.py index 4efb8277..7e74b1b2 100644 --- a/apps/api/src/services/orgs/orgs.py +++ b/apps/api/src/services/orgs/orgs.py @@ -4,7 +4,6 @@ from datetime import datetime from typing import Literal from uuid import uuid4 from sqlmodel import Session, select -from src.db.roles import Role, RoleRead from src.db.organization_config import ( AIConfig, AIEnabledFeatures, @@ -17,17 +16,15 @@ from src.db.organization_config import ( ) from src.security.rbac.rbac import ( authorization_verify_based_on_org_admin_status, - authorization_verify_based_on_roles, authorization_verify_if_user_is_anon, ) -from src.db.users import AnonymousUser, PublicUser, User, UserRead +from src.db.users import AnonymousUser, PublicUser from src.db.user_organizations import UserOrganization from src.db.organizations import ( Organization, OrganizationCreate, OrganizationRead, OrganizationUpdate, - OrganizationUser, ) from src.services.orgs.logos import upload_org_logo from fastapi import HTTPException, UploadFile, status, Request @@ -69,199 +66,6 @@ async def get_organization( return org -async def get_organization_users( - request: Request, - org_id: str, - db_session: Session, - current_user: PublicUser | AnonymousUser, -) -> list[OrganizationUser]: - statement = select(Organization).where(Organization.id == org_id) - result = db_session.exec(statement) - - org = result.first() - - if not org: - raise HTTPException( - status_code=404, - detail="Organization not found", - ) - - # RBAC check - await rbac_check(request, org.org_uuid, current_user, "read", db_session) - - statement = ( - select(User) - .join(UserOrganization) - .join(Organization) - .where(Organization.id == org_id) - ) - users = db_session.exec(statement) - users = users.all() - - org_users_list = [] - - for user in users: - statement = select(UserOrganization).where( - UserOrganization.user_id == user.id, UserOrganization.org_id == org_id - ) - result = db_session.exec(statement) - user_org = result.first() - - if not user_org: - logging.error(f"User {user.id} not found") - - # skip this user - continue - - - statement = select(Role).where(Role.id == user_org.role_id) - result = db_session.exec(statement) - - role = result.first() - - if not role: - logging.error(f"Role {user_org.role_id} not found") - - # skip this user - continue - - statement = select(User).where(User.id == user_org.user_id) - result = db_session.exec(statement) - - user = result.first() - - if not user: - logging.error(f"User {user_org.user_id} not found") - - # skip this user - continue - - user = UserRead.from_orm(user) - role = RoleRead.from_orm(role) - - org_user = OrganizationUser( - user=user, - role=role, - ) - - org_users_list.append(org_user) - - return org_users_list - - -async def remove_user_from_org( - request: Request, - org_id: int, - user_id: int, - db_session: Session, - current_user: PublicUser | AnonymousUser, -): - statement = select(Organization).where(Organization.id == org_id) - result = db_session.exec(statement) - - org = result.first() - - if not org: - raise HTTPException( - status_code=404, - detail="Organization not found", - ) - - # RBAC check - await rbac_check(request, org.org_uuid, current_user, "read", db_session) - - statement = select(UserOrganization).where( - UserOrganization.user_id == user_id, UserOrganization.org_id == org.id - ) - result = db_session.exec(statement) - - user_org = result.first() - - if not user_org: - raise HTTPException( - status_code=404, - detail="User not found", - ) - - # Check if user is the last admin - statement = select(UserOrganization).where( - UserOrganization.org_id == org.id, UserOrganization.role_id == 1 - ) - result = db_session.exec(statement) - admins = result.all() - - if len(admins) == 1 and admins[0].user_id == user_id: - raise HTTPException( - status_code=400, - detail="You can't remove the last admin of the organization", - ) - - - db_session.delete(user_org) - db_session.commit() - - return {"detail": "User removed from org"} - - -async def update_user_role( - request: Request, - org_id: str, - user_id: str, - role_uuid: str, - db_session: Session, - current_user: PublicUser | AnonymousUser, -): - # find role - statement = select(Role).where(Role.role_uuid == role_uuid) - result = db_session.exec(statement) - - role = result.first() - - if not role: - raise HTTPException( - status_code=404, - detail="Role not found", - ) - - role_id = role.id - - statement = select(Organization).where(Organization.id == org_id) - result = db_session.exec(statement) - - org = result.first() - - if not org: - raise HTTPException( - status_code=404, - detail="Organization not found", - ) - - # RBAC check - await rbac_check(request, org.org_uuid, current_user, "read", db_session) - - statement = select(UserOrganization).where( - UserOrganization.user_id == user_id, UserOrganization.org_id == org.id - ) - result = db_session.exec(statement) - - user_org = result.first() - - if not user_org: - raise HTTPException( - status_code=404, - detail="User not found", - ) - - if role_id is not None: - user_org.role_id = role_id - - db_session.add(user_org) - db_session.commit() - db_session.refresh(user_org) - - return {"detail": "User role updated"} - - async def get_organization_by_slug( request: Request, org_slug: str, @@ -634,6 +438,102 @@ async def get_orgs_by_user( return orgs +# Config related +async def update_org_signup_mechanism( + request: Request, + signup_mechanism: Literal["open", "inviteOnly"], + org_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + 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) + + # Get org config + statement = select(OrganizationConfig).where(OrganizationConfig.org_id == org.id) + result = db_session.exec(statement) + + org_config = result.first() + + if org_config is None: + logging.error(f"Organization {org_id} has no config") + raise HTTPException( + status_code=404, + detail="Organization config not found", + ) + + updated_config = org_config.config + + # Update config + updated_config = OrganizationConfigBase(**updated_config) + updated_config.GeneralConfig.users.signup_mechanism = signup_mechanism + + # Update the database + org_config.config = json.loads(updated_config.json()) + org_config.update_date = str(datetime.now()) + + db_session.add(org_config) + db_session.commit() + db_session.refresh(org_config) + + return {"detail": "Signup mechanism updated"} + + +async def get_org_join_mechanism( + request: Request, + org_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + 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) + + # Get org config + statement = select(OrganizationConfig).where(OrganizationConfig.org_id == org.id) + result = db_session.exec(statement) + + org_config = result.first() + + if org_config is None: + logging.error(f"Organization {org_id} has no config") + raise HTTPException( + status_code=404, + detail="Organization config not found", + ) + + config = org_config.config + + # Get the signup mechanism + config = OrganizationConfigBase(**config) + signup_mechanism = config.GeneralConfig.users.signup_mechanism + + return signup_mechanism + + + + ## 🔒 RBAC Utils ## @@ -649,15 +549,25 @@ async def rbac_check( return True else: - await authorization_verify_if_user_is_anon(current_user.id) + isUserAnon = await authorization_verify_if_user_is_anon(current_user.id) - await authorization_verify_based_on_roles( - request, current_user.id, action, org_uuid, db_session + isAllowedOnOrgAdminStatus = ( + await authorization_verify_based_on_org_admin_status( + 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 - ) + if isUserAnon: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="You should be logged in to be able to achieve this action", + ) + + if not isAllowedOnOrgAdminStatus: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User rights (admin status) : You don't have the right to perform this action", + ) ## 🔒 RBAC Utils ## diff --git a/apps/api/src/services/orgs/users.py b/apps/api/src/services/orgs/users.py new file mode 100644 index 00000000..28422fb5 --- /dev/null +++ b/apps/api/src/services/orgs/users.py @@ -0,0 +1,410 @@ +from datetime import datetime, timedelta +import json +import logging + +import redis +from fastapi import HTTPException, Request +from sqlmodel import Session, select +from config.config import get_learnhouse_config +from src.services.orgs.orgs import rbac_check +from src.db.roles import Role, RoleRead +from src.db.users import AnonymousUser, PublicUser, User, UserRead +from src.db.user_organizations import UserOrganization +from src.db.organizations import ( + Organization, + OrganizationUser, +) + + +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, "delete", 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, "update", db_session) + + # Check if user is the last admin and if the new role is not admin + statement = select(UserOrganization).where( + UserOrganization.org_id == org.id, UserOrganization.role_id == 1 + ) + result = db_session.exec(statement) + admins = result.all() + + if not admins: + raise HTTPException( + status_code=400, + detail="There is no admin in the organization", + ) + + if ( + len(admins) == 1 + and int(admins[0].user_id) == int(user_id) + and str(role_uuid) != "role_global_admin" + ): + raise HTTPException( + status_code=400, + detail="Organization must have at least one admin", + ) + + 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 invite_batch_users( + request: Request, + org_id: int, + emails: str, + db_session: Session, + current_user: PublicUser | AnonymousUser, +): + # 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, "create", 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", + ) + + invite_list = emails.split(",") + + # invitations expire after 30 days + ttl = int(timedelta(days=365).total_seconds()) + + for email in invite_list: + email = email.strip() + + # Check if user is already invited + invited_user = r.get(f"invited_user:{email}:org:{org.org_uuid}") + + if invited_user: + logging.error(f"User {email} already invited") + # skip this user + continue + + invited_user_object = { + "email": email, + "org_id": org.id, + "pending": True, + "email_sent": False, + "expires": ttl, + "created_at": datetime.now().isoformat(), + "created_by": current_user.user_uuid, + } + + invited_user = r.set( + f"invited_user:{email}:org:{org.org_uuid}", + json.dumps(invited_user_object), + ex=ttl, + ) + + return {"detail": "Users invited"} + + +async def get_list_of_invited_users( + request: Request, + org_id: int, + db_session: Session, + current_user: PublicUser | AnonymousUser, +): + # 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, "read", 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", + ) + + invited_users = r.keys(f"invited_user:*:org:{org.org_uuid}") + + invited_users_list = [] + + for user in invited_users: + invited_user = r.get(user) + if invited_user: + invited_user = json.loads(invited_user.decode("utf-8")) + invited_users_list.append(invited_user) + + return invited_users_list + + +async def remove_invited_user( + request: Request, + org_id: int, + email: str, + db_session: Session, + current_user: PublicUser | AnonymousUser, +): + # 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, "delete", 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", + ) + + invited_user = r.get(f"invited_user:{email}:org:{org.org_uuid}") + + if not invited_user: + raise HTTPException( + status_code=404, + detail="User not found", + ) + + r.delete(f"invited_user:{email}:org:{org.org_uuid}") + + return {"detail": "User removed"} diff --git a/apps/api/src/services/users/users.py b/apps/api/src/services/users/users.py index 3beca002..8d208b32 100644 --- a/apps/api/src/services/users/users.py +++ b/apps/api/src/services/users/users.py @@ -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.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 ( @@ -103,6 +104,27 @@ async def create_user( return user +async def create_user_with_invite( + request: Request, + db_session: Session, + current_user: PublicUser | AnonymousUser, + user_object: UserCreate, + org_id: int, + invite_code: str, +): + + # Check if invite code exists + isInviteCodeCorrect = await get_invite_code(request, org_id, invite_code, current_user, db_session) + + if not isInviteCodeCorrect: + raise HTTPException( + status_code=400, + detail="Invite code is incorrect", + ) + + user = await create_user(request, db_session, current_user, user_object, org_id) + + return user async def create_user_without_org( request: Request, diff --git a/apps/web/app/orgs/[orgslug]/dash/users/settings/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/users/settings/[subpage]/page.tsx index 1f1806ea..86aa4163 100644 --- a/apps/web/app/orgs/[orgslug]/dash/users/settings/[subpage]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/users/settings/[subpage]/page.tsx @@ -5,11 +5,12 @@ import UserEditGeneral from '@components/Dashboard/UserAccount/UserEditGeneral/U 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 { Info, Lock, ScanEye, User, UserCog, 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'; export type SettingsParams = { subpage: string @@ -19,19 +20,41 @@ export type SettingsParams = { function UsersSettingsPage({ params }: { params: SettingsParams }) { const session = useSession() as any; const org = useOrg() as any; + const [H1Label, setH1Label] = React.useState('') + const [H2Label, setH2Label] = React.useState('') + + function handleLabels() { + if (params.subpage == 'users') { + setH1Label('Users') + setH2Label('Manage your organization users, assign roles and permissions') + } + if (params.subpage == 'signups') { + setH1Label('Signup Access') + setH2Label('Choose from where users can join your organization') + } + if (params.subpage == 'add') { + setH1Label('Invite users') + setH2Label('Invite users to join your organization') + } + } + + + useEffect(() => { + handleLabels() } - , [session, org]) + , [session, org, params.subpage, params]) return (
- -
-
-
Organization Users Settings
+ +
+
+
{H1Label}
+
{H2Label}
@@ -44,6 +67,22 @@ function UsersSettingsPage({ params }: { params: SettingsParams }) {
+ +
+
+ +
Invite users
+
+
+ + +
+
+ +
Signup Access
+
+
+
@@ -55,6 +94,7 @@ function UsersSettingsPage({ params }: { params: SettingsParams }) { className='h-full overflow-y-auto' > {params.subpage == 'users' ? : ''} + {params.subpage == 'signups' ? : ''}
) diff --git a/apps/web/app/orgs/[orgslug]/login/login.tsx b/apps/web/app/orgs/[orgslug]/login/login.tsx index d8f32cb8..b5bf3c0c 100644 --- a/apps/web/app/orgs/[orgslug]/login/login.tsx +++ b/apps/web/app/orgs/[orgslug]/login/login.tsx @@ -1,11 +1,10 @@ -"use client"; +"use client";; import learnhouseIcon from "public/learnhouse_bigicon_1.png"; -import FormLayout, { ButtonBlack, FormField, FormLabel, FormLabelAndMessage, FormMessage, Input } from '@components/StyledElements/Form/Form' +import FormLayout, { FormField, FormLabelAndMessage, Input } from '@components/StyledElements/Form/Form'; import Image from 'next/image'; import * as Form from '@radix-ui/react-form'; import { useFormik } from 'formik'; import { getOrgLogoMediaDirectory } from "@services/media/media"; -import { BarLoader } from "react-spinners"; import React from "react"; import { loginAndGetToken } from "@services/auth/auth"; import { AlertTriangle } from "lucide-react"; @@ -79,14 +78,14 @@ const LoginClient = (props: LoginClientProps) => {
Login to
- {props.org?.logo ? ( + {props.org?.logo_image ? ( Learnhouse - ) : ( + ) : ( )}
diff --git a/apps/web/app/orgs/[orgslug]/signup/InviteOnlySignUp.tsx b/apps/web/app/orgs/[orgslug]/signup/InviteOnlySignUp.tsx new file mode 100644 index 00000000..1f554fd2 --- /dev/null +++ b/apps/web/app/orgs/[orgslug]/signup/InviteOnlySignUp.tsx @@ -0,0 +1,166 @@ +"use client"; +import { useFormik } from 'formik'; +import { useRouter } from 'next/navigation'; +import React, { useEffect } from 'react' +import FormLayout, { FormField, FormLabelAndMessage, Input, Textarea } from '@components/StyledElements/Form/Form'; +import * as Form from '@radix-ui/react-form'; +import { AlertTriangle, Check, User } from 'lucide-react'; +import Link from 'next/link'; +import { signUpWithInviteCode } from '@services/auth/auth'; +import { useOrg } from '@components/Contexts/OrgContext'; + + + + +const validate = (values: any) => { + const errors: any = {}; + + if (!values.email) { + errors.email = 'Required'; + } + else if ( + !/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(values.email) + ) { + errors.email = 'Invalid email address'; + } + + if (!values.password) { + errors.password = 'Required'; + } + else if (values.password.length < 8) { + errors.password = 'Password must be at least 8 characters'; + } + + if (!values.username) { + errors.username = 'Required'; + } + + if (!values.username || values.username.length < 4) { + errors.username = 'Username must be at least 4 characters'; + } + + if (!values.bio) { + errors.bio = 'Required'; + } + + + return errors; +}; + +interface InviteOnlySignUpProps { + inviteCode: string; +} + +function InviteOnlySignUpComponent(props : InviteOnlySignUpProps) { + const [isSubmitting, setIsSubmitting] = React.useState(false); + const org = useOrg() as any; + const router = useRouter(); + const [error, setError] = React.useState(''); + const [message, setMessage] = React.useState(''); + const formik = useFormik({ + initialValues: { + org_slug: org?.slug, + org_id: org?.id, + email: '', + password: '', + username: '', + bio: '', + first_name: '', + last_name: '', + }, + validate, + onSubmit: async values => { + setError('') + setMessage('') + setIsSubmitting(true); + let res = await signUpWithInviteCode(values, props.inviteCode); + let message = await res.json(); + if (res.status == 200) { + //router.push(`/login`); + setMessage('Your account was successfully created') + setIsSubmitting(false); + } + else if (res.status == 401 || res.status == 400 || res.status == 404 || res.status == 409) { + setError(message.detail); + setIsSubmitting(false); + + } + else { + setError("Something went wrong"); + setIsSubmitting(false); + } + + }, + }); + + useEffect(() => { + + } + , [org]); + + return ( +
+ {error && ( +
+ +
{error}
+
+ )} + {message && ( +
+
+ +
{message}
+
+
+
Login
+
+ )} + + + + + + + + {/* for password */} + + + + + + + + {/* for username */} + + + + + + + + + {/* for bio */} + + + + +