feat: invite-only org signup

This commit is contained in:
swve 2024-01-25 23:37:16 +01:00
parent 689625b0d5
commit 0d775a0fe9
22 changed files with 1733 additions and 387 deletions

View file

@ -1,6 +1,20 @@
from typing import List from typing import List, Literal
from fastapi import APIRouter, Depends, Request, UploadFile from fastapi import APIRouter, Depends, Request, UploadFile
from sqlmodel import Session 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.organization_config import OrganizationConfigBase
from src.db.users import PublicUser from src.db.users import PublicUser
from src.db.organizations import ( from src.db.organizations import (
@ -18,12 +32,10 @@ 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, 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}") @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

@ -1,6 +1,7 @@
from typing import Literal from typing import Literal
from fastapi import APIRouter, Depends, Request, UploadFile from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
from sqlmodel import Session from sqlmodel import Session
from src.services.orgs.orgs import get_org_join_mechanism
from src.security.auth import get_current_user from src.security.auth import get_current_user
from src.core.events.database import get_db_session from src.core.events.database import get_db_session
@ -16,6 +17,7 @@ from src.db.users import (
from src.services.users.users import ( from src.services.users.users import (
authorize_user_action, authorize_user_action,
create_user, create_user,
create_user_with_invite,
create_user_without_org, create_user_without_org,
delete_user_by_id, delete_user_by_id,
get_user_session, get_user_session,
@ -78,9 +80,50 @@ async def api_create_user_with_orgid(
""" """
Create User with Org ID Create User with 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) 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"]) @router.post("/", response_model=UserRead, tags=["users"])
async def api_create_user_without_org( async def api_create_user_without_org(
*, *,

View file

@ -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

View file

@ -9,7 +9,7 @@ async def upload_org_logo(logo_file, org_uuid):
await upload_content( await upload_content(
"logos", "logos",
org_uuid, "orgs",
org_uuid, org_uuid,
contents, contents,
name_in_disk, name_in_disk,

View file

@ -4,7 +4,6 @@ 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,
@ -17,17 +16,15 @@ from src.db.organization_config import (
) )
from src.security.rbac.rbac import ( from src.security.rbac.rbac import (
authorization_verify_based_on_org_admin_status, 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, User, UserRead from src.db.users import AnonymousUser, PublicUser
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
@ -69,199 +66,6 @@ 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,
@ -634,6 +438,102 @@ async def get_orgs_by_user(
return orgs 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 ## ## 🔒 RBAC Utils ##
@ -649,15 +549,25 @@ async def rbac_check(
return True return True
else: 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( await authorization_verify_based_on_org_admin_status(
request, current_user.id, action, org_uuid, db_session 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 ## ## 🔒 RBAC Utils ##

View file

@ -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"}

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.orgs.invites import get_invite_code
from src.services.users.avatars import upload_avatar from src.services.users.avatars import upload_avatar
from src.db.roles import Role, RoleRead from src.db.roles import Role, RoleRead
from src.security.rbac.rbac import ( from src.security.rbac.rbac import (
@ -103,6 +104,27 @@ async def create_user(
return 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( async def create_user_without_org(
request: Request, request: Request,

View file

@ -5,11 +5,12 @@ import UserEditGeneral from '@components/Dashboard/UserAccount/UserEditGeneral/U
import UserEditPassword from '@components/Dashboard/UserAccount/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, User, Users } from 'lucide-react'; import { Info, Lock, ScanEye, User, UserCog, UserPlus, Users } from 'lucide-react';
import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs'; import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs';
import { useSession } from '@components/Contexts/SessionContext'; import { useSession } from '@components/Contexts/SessionContext';
import { useOrg } from '@components/Contexts/OrgContext'; import { useOrg } from '@components/Contexts/OrgContext';
import OrgUsers from '@components/Dashboard/Users/OrgUsers/OrgUsers'; import OrgUsers from '@components/Dashboard/Users/OrgUsers/OrgUsers';
import OrgAccess from '@components/Dashboard/Users/OrgAccess/OrgAccess';
export type SettingsParams = { export type SettingsParams = {
subpage: string subpage: string
@ -19,19 +20,41 @@ export type SettingsParams = {
function UsersSettingsPage({ params }: { params: SettingsParams }) { function UsersSettingsPage({ params }: { params: SettingsParams }) {
const session = useSession() as any; const session = useSession() as any;
const org = useOrg() 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(() => { useEffect(() => {
handleLabels()
} }
, [session, org]) , [session, org, params.subpage, params])
return ( return (
<div className='h-screen w-full bg-[#f8f8f8] grid grid-rows-[auto,1fr]'> <div className='h-screen w-full bg-[#f8f8f8] grid grid-rows-[auto,1fr]'>
<div className='pl-10 pr-10 tracking-tight bg-[#fcfbfc] z-10 shadow-[0px_4px_16px_rgba(0,0,0,0.06)]'> <div className='pl-10 pr-10 tracking-tight bg-[#fcfbfc] z-10 shadow-[0px_4px_16px_rgba(0,0,0,0.06)]'>
<BreadCrumbs type='org' last_breadcrumb='User settings' ></BreadCrumbs> <BreadCrumbs type='orgusers' ></BreadCrumbs>
<div className='my-2 tracking-tighter'> <div className='my-2 py-3'>
<div className='w-100 flex justify-between'> <div className='w-100 flex flex-col space-y-1'>
<div className='pt-3 flex font-bold text-4xl'>Organization Users Settings</div> <div className='pt-3 flex font-bold text-4xl tracking-tighter'>{H1Label}</div>
<div className='flex font-medium text-gray-400 text-md'>{H2Label} </div>
</div> </div>
</div> </div>
<div className='flex space-x-5 font-black text-sm'> <div className='flex space-x-5 font-black text-sm'>
@ -44,6 +67,22 @@ 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 href={getUriWithOrg(params.orgslug, "") + `/dash/users/settings/signups`}>
<div className={`py-2 w-fit text-center border-black transition-all ease-linear ${params.subpage.toString() === 'signups' ? 'border-b-4' : 'opacity-50'} cursor-pointer`}>
<div className='flex items-center space-x-2.5 mx-2'>
<ScanEye size={16} />
<div>Signup Access</div>
</div>
</div>
</Link>
</div> </div>
</div> </div>
@ -55,6 +94,7 @@ function UsersSettingsPage({ params }: { params: SettingsParams }) {
className='h-full overflow-y-auto' className='h-full overflow-y-auto'
> >
{params.subpage == 'users' ? <OrgUsers /> : ''} {params.subpage == 'users' ? <OrgUsers /> : ''}
{params.subpage == 'signups' ? <OrgAccess /> : ''}
</motion.div> </motion.div>
</div> </div>
) )

View file

@ -1,11 +1,10 @@
"use client"; "use client";;
import learnhouseIcon from "public/learnhouse_bigicon_1.png"; 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 Image from 'next/image';
import * as Form from '@radix-ui/react-form'; import * as Form from '@radix-ui/react-form';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import { getOrgLogoMediaDirectory } from "@services/media/media"; import { getOrgLogoMediaDirectory } from "@services/media/media";
import { BarLoader } from "react-spinners";
import React from "react"; import React from "react";
import { loginAndGetToken } from "@services/auth/auth"; import { loginAndGetToken } from "@services/auth/auth";
import { AlertTriangle } from "lucide-react"; import { AlertTriangle } from "lucide-react";
@ -79,12 +78,12 @@ const LoginClient = (props: LoginClientProps) => {
<div className="m-auto flex space-x-4 items-center flex-wrap"> <div className="m-auto flex space-x-4 items-center flex-wrap">
<div>Login to </div> <div>Login to </div>
<div className="shadow-[0px_4px_16px_rgba(0,0,0,0.02)]" > <div className="shadow-[0px_4px_16px_rgba(0,0,0,0.02)]" >
{props.org?.logo ? ( {props.org?.logo_image ? (
<img <img
src={`${getOrgLogoMediaDirectory(props.org.org_id, props.org?.logo)}`} src={`${getOrgLogoMediaDirectory(props.org.org_uuid, props.org?.logo_image)}`}
alt="Learnhouse" alt="Learnhouse"
style={{ width: "auto", height: 70 }} style={{ width: "auto", height: 70 }}
className="rounded-md shadow-xl inset-0 ring-1 ring-inset ring-black/10 bg-white" className="rounded-xl shadow-xl inset-0 ring-1 ring-inset ring-black/10 bg-white"
/> />
) : ( ) : (
<Image quality={100} width={70} height={70} src={learnhouseIcon} alt="" /> <Image quality={100} width={70} height={70} src={learnhouseIcon} alt="" />

View file

@ -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 (
<div className="login-form m-auto w-72">
{error && (
<div className="flex justify-center bg-red-200 rounded-md text-red-950 space-x-2 items-center p-4 transition-all shadow-sm">
<AlertTriangle size={18} />
<div className="font-bold text-sm">{error}</div>
</div>
)}
{message && (
<div className="flex flex-col space-y-4 justify-center bg-green-200 rounded-md text-green-950 space-x-2 items-center p-4 transition-all shadow-sm">
<div className='flex space-x-2'>
<Check size={18} />
<div className="font-bold text-sm">{message}</div>
</div>
<hr className='border-green-900/20 800 w-40 border' />
<Link className='flex space-x-2 items-center' href={'/login'}><User size={14} /> <div>Login </div></Link>
</div>
)}
<FormLayout onSubmit={formik.handleSubmit}>
<FormField name="email">
<FormLabelAndMessage label='Email' message={formik.errors.email} />
<Form.Control asChild>
<Input onChange={formik.handleChange} value={formik.values.email} type="email" required />
</Form.Control>
</FormField>
{/* for password */}
<FormField name="password">
<FormLabelAndMessage label='Password' message={formik.errors.password} />
<Form.Control asChild>
<Input onChange={formik.handleChange} value={formik.values.password} type="password" required />
</Form.Control>
</FormField>
{/* for username */}
<FormField name="username">
<FormLabelAndMessage label='Username' message={formik.errors.username} />
<Form.Control asChild>
<Input onChange={formik.handleChange} value={formik.values.username} type="text" required />
</Form.Control>
</FormField>
{/* for bio */}
<FormField name="bio">
<FormLabelAndMessage label='Bio' message={formik.errors.bio} />
<Form.Control asChild>
<Textarea onChange={formik.handleChange} value={formik.values.bio} required />
</Form.Control>
</FormField>
<div className="flex py-4">
<Form.Submit asChild>
<button className="w-full bg-black text-white font-bold text-center p-2 rounded-md shadow-md hover:cursor-pointer" >
{isSubmitting ? "Loading..."
: "Create an account & Join"}
</button>
</Form.Submit>
</div>
</FormLayout>
</div>
)
}
export default InviteOnlySignUpComponent

View file

@ -0,0 +1,163 @@
"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 { signup } 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;
};
function OpenSignUpComponent() {
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 signup(values);
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 (
<div className="login-form m-auto w-72">
{error && (
<div className="flex justify-center bg-red-200 rounded-md text-red-950 space-x-2 items-center p-4 transition-all shadow-sm">
<AlertTriangle size={18} />
<div className="font-bold text-sm">{error}</div>
</div>
)}
{message && (
<div className="flex flex-col space-y-4 justify-center bg-green-200 rounded-md text-green-950 space-x-2 items-center p-4 transition-all shadow-sm">
<div className='flex space-x-2'>
<Check size={18} />
<div className="font-bold text-sm">{message}</div>
</div>
<hr className='border-green-900/20 800 w-40 border' />
<Link className='flex space-x-2 items-center' href={'/login'}><User size={14} /> <div>Login </div></Link>
</div>
)}
<FormLayout onSubmit={formik.handleSubmit}>
<FormField name="email">
<FormLabelAndMessage label='Email' message={formik.errors.email} />
<Form.Control asChild>
<Input onChange={formik.handleChange} value={formik.values.email} type="email" required />
</Form.Control>
</FormField>
{/* for password */}
<FormField name="password">
<FormLabelAndMessage label='Password' message={formik.errors.password} />
<Form.Control asChild>
<Input onChange={formik.handleChange} value={formik.values.password} type="password" required />
</Form.Control>
</FormField>
{/* for username */}
<FormField name="username">
<FormLabelAndMessage label='Username' message={formik.errors.username} />
<Form.Control asChild>
<Input onChange={formik.handleChange} value={formik.values.username} type="text" required />
</Form.Control>
</FormField>
{/* for bio */}
<FormField name="bio">
<FormLabelAndMessage label='Bio' message={formik.errors.bio} />
<Form.Control asChild>
<Textarea onChange={formik.handleChange} value={formik.values.bio} required />
</Form.Control>
</FormField>
<div className="flex py-4">
<Form.Submit asChild>
<button className="w-full bg-black text-white font-bold text-center p-2 rounded-md shadow-md hover:cursor-pointer" >
{isSubmitting ? "Loading..."
: "Create an account"}
</button>
</Form.Submit>
</div>
</FormLayout>
</div>
)
}
export default OpenSignUpComponent

View file

@ -1,8 +1,9 @@
import React from "react";
import SignUpClient from "./signup";
import { Metadata } from "next"; import { Metadata } from "next";
import { getOrganizationContextInfo } from "@services/organizations/orgs"; import { getOrganizationContextInfo } from "@services/organizations/orgs";
import SignUpClient from "./signup";
import { Suspense } from "react";
import PageLoading from "@components/Objects/Loaders/PageLoading";
type MetadataProps = { type MetadataProps = {
params: { orgslug: string, courseid: string }; params: { orgslug: string, courseid: string };
@ -26,9 +27,11 @@ const SignUp = async (params: any) => {
const org = await getOrganizationContextInfo(orgslug, { revalidate: 1800, tags: ['organizations'] }); const org = await getOrganizationContextInfo(orgslug, { revalidate: 1800, tags: ['organizations'] });
return ( return (
<div> <>
<SignUpClient org={org}></SignUpClient> <Suspense fallback={<PageLoading/>}>
</div> <SignUpClient org={org} />
</Suspense>
</>
); );
}; };
export default SignUp; export default SignUp;

View file

@ -1,117 +1,62 @@
"use client"; "use client";
import { useFormik } from 'formik';
import { useRouter } from 'next/navigation';
import learnhouseIcon from "public/learnhouse_bigicon_1.png"; import learnhouseIcon from "public/learnhouse_bigicon_1.png";
import React from 'react'
import FormLayout, { ButtonBlack, FormField, FormLabel, FormLabelAndMessage, FormMessage, Input, Textarea } from '@components/StyledElements/Form/Form'
import Image from 'next/image'; import Image from 'next/image';
import * as Form from '@radix-ui/react-form';
import { getOrgLogoMediaDirectory } from '@services/media/media'; import { getOrgLogoMediaDirectory } from '@services/media/media';
import { AlertTriangle, Check, User } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { signup } from '@services/auth/auth';
import { getUriWithOrg } from '@services/config/config'; import { getUriWithOrg } from '@services/config/config';
import { useSession } from "@components/Contexts/SessionContext";
import React, { useEffect } from "react";
import { MailWarning, Shield, UserPlus } from "lucide-react";
import { useOrg } from "@components/Contexts/OrgContext";
import UserAvatar from "@components/Objects/UserAvatar";
import OpenSignUpComponent from "./OpenSignup";
import InviteOnlySignUpComponent from "./InviteOnlySignUp";
import { useRouter, useSearchParams } from "next/navigation";
import { validateInviteCode } from "@services/organizations/invites";
import PageLoading from "@components/Objects/Loaders/PageLoading";
import Toast from "@components/StyledElements/Toast/Toast";
import toast from "react-hot-toast";
interface SignUpClientProps { interface SignUpClientProps {
org: any; org: any;
}
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) { function SignUpClient(props : SignUpClientProps) {
errors.password = 'Required'; const session = useSession() as any;
const [joinMethod, setJoinMethod] = React.useState('open');
const [inviteCode , setInviteCode] = React.useState('');
const searchParams = useSearchParams()
const inviteCodeParam = searchParams.get('inviteCode')
useEffect(() => {
if (props.org.config){
setJoinMethod(props.org?.config?.config?.GeneralConfig.users.signup_mechanism);
console.log(props.org?.config?.config?.GeneralConfig.users.signup_mechanism)
} }
else if (values.password.length < 8) { if (inviteCodeParam){
errors.password = 'Password must be at least 8 characters'; setInviteCode(inviteCodeParam);
} }
if (!values.username) {
errors.username = 'Required';
} }
, [props.org,inviteCodeParam]);
if (!values.username || values.username.length < 4) {
errors.username = 'Username must be at least 4 characters';
}
if (!values.bio) {
errors.bio = 'Required';
}
return errors;
};
function SignUpClient(props: SignUpClientProps) {
const [isSubmitting, setIsSubmitting] = React.useState(false);
const router = useRouter();
const [error, setError] = React.useState('');
const [message, setMessage] = React.useState('');
const formik = useFormik({
initialValues: {
org_slug: props.org?.slug,
org_id: props.org?.id,
email: '',
password: '',
username: '',
bio: '',
first_name: '',
last_name: '',
},
validate,
onSubmit: async values => {
setError('')
setMessage('')
setIsSubmitting(true);
let res = await signup(values);
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);
}
},
});
return ( return (
<div><div className='grid grid-flow-col justify-stretch h-screen'> <div className='grid grid-flow-col justify-stretch h-screen'>
<div className="right-login-part" style={{ background: "linear-gradient(041.61deg, #202020 7.15%, #000000 90.96%)" }} > <div className="right-login-part" style={{ background: "linear-gradient(041.61deg, #202020 7.15%, #000000 90.96%)" }} >
<div className='login-topbar m-10'> <div className='login-topbar m-10'>
<Link prefetch href={getUriWithOrg(props.org.slug, "/")}> <Link prefetch href={getUriWithOrg(props.org.slug, "/")}>
<Image quality={100} width={30} height={30} src={learnhouseIcon} alt="" /> <Image quality={100} width={30} height={30} src={learnhouseIcon} alt="" />
</Link> </Link>
</div> </div>
<div className="ml-10 h-4/6 flex flex-row text-white"> <div className="ml-10 h-3/4 flex flex-row text-white">
<div className="m-auto flex space-x-4 items-center flex-wrap"> <div className="m-auto flex space-x-4 items-center flex-wrap">
<div>Join </div> <div>You've been invited to join </div>
<div className="shadow-[0px_4px_16px_rgba(0,0,0,0.02)]" > <div className="shadow-[0px_4px_16px_rgba(0,0,0,0.02)]" >
{props.org?.logo ? ( {props.org?.logo_image ? (
<img <img
src={`${getOrgLogoMediaDirectory(props.org.org_id, props.org?.logo)}`} src={`${getOrgLogoMediaDirectory(props.org.org_uuid, props.org?.logo_image)}`}
alt="Learnhouse" alt="Learnhouse"
style={{ width: "auto", height: 70 }} style={{ width: "auto", height: 70 }}
className="rounded-md shadow-xl inset-0 ring-1 ring-inset ring-black/10 bg-white" className="rounded-xl shadow-xl inset-0 ring-1 ring-inset ring-black/10 bg-white"
/> />
) : ( ) : (
<Image quality={100} width={70} height={70} src={learnhouseIcon} alt="" /> <Image quality={100} width={70} height={70} src={learnhouseIcon} alt="" />
@ -121,70 +66,109 @@ function SignUpClient(props: SignUpClientProps) {
</div> </div>
</div> </div>
</div> </div>
<div className="left-login-part bg-white flex flex-row"> <div className="left-join-part bg-white flex flex-row">
<div className="login-form m-auto w-72"> {joinMethod == 'open' && <OpenSignUpComponent />}
{error && ( {joinMethod == 'inviteOnly' && inviteCode && session.isAuthenticated && <LoggedInJoinScreen />}
<div className="flex justify-center bg-red-200 rounded-md text-red-950 space-x-2 items-center p-4 transition-all shadow-sm"> {joinMethod == 'inviteOnly' && inviteCode && !session.isAuthenticated && <InviteOnlySignUpComponent inviteCode={inviteCode} />}
<AlertTriangle size={18} /> {joinMethod == 'inviteOnly' && !inviteCode && <NoTokenScreen />}
<div className="font-bold text-sm">{error}</div>
</div> </div>
)}
{message && (
<div className="flex flex-col space-y-4 justify-center bg-green-200 rounded-md text-green-950 space-x-2 items-center p-4 transition-all shadow-sm">
<div className='flex space-x-2'>
<Check size={18} />
<div className="font-bold text-sm">{message}</div>
</div> </div>
<hr className='border-green-900/20 800 w-40 border' /> )
<Link className='flex space-x-2 items-center' href={'/login'}><User size={14} /> <div>Login </div></Link> }
</div>
)}
<FormLayout onSubmit={formik.handleSubmit}>
<FormField name="email">
<FormLabelAndMessage label='Email' message={formik.errors.email} />
<Form.Control asChild>
<Input onChange={formik.handleChange} value={formik.values.email} type="email" required />
</Form.Control>
</FormField>
{/* for password */}
<FormField name="password">
<FormLabelAndMessage label='Password' message={formik.errors.password} />
<Form.Control asChild>
<Input onChange={formik.handleChange} value={formik.values.password} type="password" required />
</Form.Control>
</FormField>
{/* for username */}
<FormField name="username">
<FormLabelAndMessage label='Username' message={formik.errors.username} />
<Form.Control asChild>
<Input onChange={formik.handleChange} value={formik.values.username} type="text" required />
</Form.Control>
</FormField>
{/* for bio */}
<FormField name="bio">
<FormLabelAndMessage label='Bio' message={formik.errors.bio} />
<Form.Control asChild>
<Textarea onChange={formik.handleChange} value={formik.values.bio} required />
</Form.Control>
</FormField>
<div className="flex py-4"> const LoggedInJoinScreen = (props: any) => {
<Form.Submit asChild> const session = useSession() as any;
<button className="w-full bg-black text-white font-bold text-center p-2 rounded-md shadow-md hover:cursor-pointer" > const org = useOrg() as any;
{isSubmitting ? "Loading..." const [isLoading, setIsLoading] = React.useState(true);
: "Create an account"}
useEffect(() => {
if(session && org){
setIsLoading(false);
}
}
, [org, session]);
return (
<div className="flex flex-row items-center mx-auto">
<div className="flex space-y-7 flex-col justify-center items-center">
<p className='pt-3 text-2xl font-semibold text-black/70 flex justify-center space-x-2 items-center'>
<span className='items-center'>Hi</span>
<span className='capitalize flex space-x-2 items-center'>
<UserAvatar rounded='rounded-xl' border='border-4' width={35} />
<span>{session.user.username},</span>
</span>
<span>join {org?.name} ?</span>
</p>
<button 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">
<UserPlus size={18} />
<p>Join </p>
</button> </button>
</Form.Submit>
</div> </div>
</div>
)
</FormLayout> }
const NoTokenScreen = (props: any) => {
const session = useSession() as any;
const org = useOrg() as any;
const router = useRouter();
const [isLoading, setIsLoading] = React.useState(true);
const [inviteCode , setInviteCode] = React.useState('');
const [messsage, setMessage] = React.useState('bruh');
const handleInviteCodeChange = (e: any) => {
setInviteCode(e.target.value);
}
const validateCode = async () => {
setIsLoading(true);
let res = await validateInviteCode(org?.id,inviteCode);
//wait for 1s
if (res.success) {
toast.success("Invite code is valid, you'll be redirected to the signup page in a few seconds");
setTimeout(() => {
router.push(`/signup?inviteCode=${inviteCode}`);
}, 2000);
}
else {
toast.error("Invite code is invalid");
setIsLoading(false);
}
}
useEffect(() => {
if(session && org){
setIsLoading(false);
}
}
, [org, session]);
return (
<div className="flex flex-row items-center mx-auto">
<Toast />
{isLoading ? <div className="flex space-y-7 flex-col w-[300px] justify-center items-center"><PageLoading /></div> : <div className="flex space-y-7 flex-col justify-center items-center">
<p className="flex space-x-2 text-lg font-medium text-red-800 items-center">
<MailWarning size={18} />
<span>An invite code is required to join {org?.name}</span>
</p>
<input onChange={handleInviteCodeChange} className="bg-white outline-2 outline outline-gray-200 rounded-lg px-5 w-[300px] h-[50px]" placeholder="Please enter an invite code" type="text" />
<button onClick={validateCode} className="flex w-fit space-x-2 bg-black px-6 py-2 text-md rounded-lg font-semibold h-fit text-white items-center shadow-md">
<Shield size={18} />
<p>Submit </p>
</button>
</div> }
</div> </div>
</div>
</div></div>
) )
} }

View file

@ -1,10 +1,10 @@
import { useCourse } from '@components/Contexts/CourseContext' import { useCourse } from '@components/Contexts/CourseContext'
import { Book, ChevronRight, School, User } from 'lucide-react' import { Book, ChevronRight, School, User, Users } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import React, { use, useEffect } from 'react' import React, { use, useEffect } from 'react'
type BreadCrumbsProps = { type BreadCrumbsProps = {
type: 'courses' | 'user' | 'users' | 'org' type: 'courses' | 'user' | 'users' | 'org' | 'orgusers'
last_breadcrumb?: string last_breadcrumb?: string
} }
@ -18,6 +18,8 @@ function BreadCrumbs(props: BreadCrumbsProps) {
<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-account/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 == 'orgusers' ? <div className='flex space-x-2 items-center'> <Users className='text-gray' size={14}></Users><Link href='/dash/users/settings/users'>Organization users</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

@ -42,8 +42,8 @@ function LeftMenu() {
return ( return (
<div <div
style={{ background: "linear-gradient(0deg, rgba(0, 0, 0, 0.20) 0%, rgba(0, 0, 0, 0.20) 100%), radial-gradient(271.56% 105.16% at 50% -5.16%, rgba(255, 255, 255, 0.18) 0%, rgba(0, 0, 0, 0.00) 100%), #2E2D2D" }} style={{ background: "linear-gradient(0deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.2) 100%), radial-gradient(271.56% 105.16% at 50% -5.16%, rgba(255, 255, 255, 0.18) 0%, rgba(0, 0, 0, 0) 100%), rgb(20 19 19)"}}
className='flex flex-col w-28 bg-black h-screen text-white shadow-xl'> className='flex flex-col w-[90px] bg-black h-screen text-white shadow-xl'>
<div className='flex flex-col h-full'> <div className='flex flex-col h-full'>
<div className='flex h-20 mt-6'> <div className='flex h-20 mt-6'>
<Link className='flex flex-col items-center mx-auto space-y-3' href={"/"}> <Link className='flex flex-col items-center mx-auto space-y-3' href={"/"}>

View file

@ -0,0 +1,175 @@
import { useOrg } from '@components/Contexts/OrgContext'
import PageLoading from '@components/Objects/Loaders/PageLoading';
import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal';
import { getAPIUrl, getUriWithOrg } from '@services/config/config';
import { swrFetcher } from '@services/utils/ts/requests';
import { Globe, Shield, X } from 'lucide-react'
import Link from 'next/link';
import React, { use, useEffect } from 'react'
import useSWR, { mutate } from 'swr';
import dayjs from 'dayjs';
import { changeSignupMechanism, createInviteCode, deleteInviteCode } from '@services/organizations/invites';
import Toast from '@components/StyledElements/Toast/Toast';
import toast from 'react-hot-toast';
import { useRouter } from 'next/navigation';
function OrgAccess() {
const org = useOrg() as any;
const { data: invites } = useSWR(org ? `${getAPIUrl()}orgs/${org?.id}/invites` : null, swrFetcher);
const [isLoading, setIsLoading] = React.useState(false)
const [joinMethod, setJoinMethod] = React.useState('closed')
const router = useRouter()
async function getOrgJoinMethod() {
if (org) {
if (org.config.config.GeneralConfig.users.signup_mechanism == 'open') {
setJoinMethod('open')
}
else {
setJoinMethod('inviteOnly')
}
}
}
async function createInvite() {
let res = await createInviteCode(org.id)
if (res.status == 200) {
mutate(`${getAPIUrl()}orgs/${org.id}/invites`)
}
else {
toast.error('Error ' + res.status + ': ' + res.data.detail)
}
}
async function deleteInvite(invite: any) {
let res = await deleteInviteCode(org.id, invite.invite_code_uuid)
if (res.status == 200) {
mutate(`${getAPIUrl()}orgs/${org.id}/invites`)
}
else {
toast.error('Error ' + res.status + ': ' + res.data.detail)
}
}
async function changeJoinMethod(method: 'open' | 'inviteOnly') {
let res = await changeSignupMechanism(org.id, method)
if (res.status == 200) {
router.refresh()
mutate(`${getAPIUrl()}orgs/slug/${org?.slug}`)
}
else {
toast.error('Error ' + res.status + ': ' + res.data.detail)
}
}
useEffect(() => {
if (invites && org) {
getOrgJoinMethod()
setIsLoading(false)
}
}
, [org, invites])
return (
<>
<Toast></Toast>
{!isLoading ? (<>
<div className="h-6"></div>
<div className='ml-10 mr-10 mx-auto bg-white rounded-xl shadow-sm px-4 py-4 anit '>
<div className='flex flex-col bg-gray-50 -space-y-1 px-5 py-3 rounded-md mb-3 '>
<h1 className='font-bold text-xl text-gray-800'>Join method</h1>
<h2 className='text-gray-500 text-md'> Choose how users can join your organization </h2>
</div>
<div className='flex space-x-2 mx-auto'>
<ConfirmationModal
confirmationButtonText='Change to open '
confirmationMessage='Are you sure you want to change the signup mechanism to open ? This will allow users to join your organization freely.'
dialogTitle={'Change to open ?'}
dialogTrigger={
<div className='w-full h-[160px] bg-slate-100 rounded-lg cursor-pointer hover:bg-slate-200 ease-linear transition-all'>
{joinMethod == 'open' ? <div className='bg-green-200 text-green-600 font-bold w-fit my-3 mx-3 absolute text-sm px-3 py-1 rounded-lg'>Active</div> : null}
<div className='flex flex-col space-y-1 justify-center items-center h-full'>
<Globe className='text-slate-400' size={40}></Globe>
<div className='text-2xl text-slate-700 font-bold'>Open</div>
<div className='text-gray-400 text-center'>Users can join freely from the signup page</div>
</div>
</div>}
functionToExecute={() => { changeJoinMethod('open') }}
status='info'
></ConfirmationModal>
<ConfirmationModal
confirmationButtonText='Change to closed '
confirmationMessage='Are you sure you want to change the signup mechanism to closed ? This will allow users to join your organization only by invitation.'
dialogTitle={'Change to closed ?'}
dialogTrigger={
<div className='w-full h-[160px] bg-slate-100 rounded-lg cursor-pointer hover:bg-slate-200 ease-linear transition-all'>
{joinMethod == 'inviteOnly' ? <div className='bg-green-200 text-green-600 font-bold w-fit my-3 mx-3 absolute text-sm px-3 py-1 rounded-lg'>Active</div> : null}
<div className='flex flex-col space-y-1 justify-center items-center h-full'>
<Shield className='text-slate-400' size={40}></Shield>
<div className='text-2xl text-slate-700 font-bold'>Closed</div>
<div className='text-gray-400 text-center'>Users can join only by invitation</div>
</div>
</div>}
functionToExecute={() => { changeJoinMethod('inviteOnly') }}
status='info'
></ConfirmationModal>
</div>
<div className={joinMethod == 'open' ? 'opacity-20 pointer-events-none' : 'pointer-events-auto'}>
<div className='flex flex-col bg-gray-50 -space-y-1 px-5 py-3 rounded-md mt-3 mb-3 '>
<h1 className='font-bold text-xl text-gray-800'>Invite codes</h1>
<h2 className='text-gray-500 text-md'>Invite codes can be copied and used to join your organization </h2>
</div>
<table className="table-auto w-full text-left whitespace-nowrap rounded-md overflow-hidden">
<thead className='bg-gray-100 text-gray-500 rounded-xl uppercase'>
<tr className='font-bolder text-sm'>
<th className='py-3 px-4'>Code</th>
<th className='py-3 px-4'>Signup link</th>
<th className='py-3 px-4'>Expiration date</th>
<th className='py-3 px-4'>Actions</th>
</tr>
</thead>
<>
<tbody className='mt-5 bg-white rounded-md' >
{invites?.map((invite: any) => (
<tr key={invite.invite_code_uuid} className='border-b border-gray-100 text-sm'>
<td className='py-3 px-4'>{invite.invite_code}</td>
<td className='py-3 px-4 '>
<Link className='outline bg-gray-50 text-gray-600 px-2 py-1 rounded-md outline-gray-300 outline-dashed outline-1' target='_blank' href={getUriWithOrg(org?.slug, `/signup?inviteCode=${invite.invite_code}`)}>
{getUriWithOrg(org?.slug, `/signup?inviteCode=${invite.invite_code}`)}
</Link>
</td>
<td className='py-3 px-4'>{dayjs(invite.expiration_date).add(1, 'year').format('DD/MM/YYYY')} </td>
<td className='py-3 px-4'>
<ConfirmationModal
confirmationButtonText='Delete Code'
confirmationMessage='Are you sure you want remove this invite code ?'
dialogTitle={'Delete code ?'}
dialogTrigger={
<button className='mr-2 flex space-x-2 hover:cursor-pointer p-1 px-3 bg-rose-700 rounded-md font-bold items-center text-sm text-rose-100'>
<X className='w-4 h-4' />
<span> Delete code</span>
</button>}
functionToExecute={() => { deleteInvite(invite) }}
status='warning'
></ConfirmationModal>
</td>
</tr>
))}
</tbody>
</>
</table>
<button onClick={() => createInvite()} className='mt-3 mr-2 flex space-x-2 hover:cursor-pointer p-1 px-3 bg-green-700 rounded-md font-bold items-center text-sm text-green-100'>
<Shield className='w-4 h-4' />
<span> Create invite code</span>
</button>
</div>
</div></>) : <PageLoading />}
</>
)
}
export default OrgAccess

View file

@ -49,6 +49,10 @@ function OrgUsers() {
<Toast></Toast> <Toast></Toast>
<div className="h-6"></div> <div className="h-6"></div>
<div className='ml-10 mr-10 mx-auto bg-white rounded-xl shadow-sm px-4 py-4 '> <div className='ml-10 mr-10 mx-auto bg-white rounded-xl shadow-sm px-4 py-4 '>
<div className='flex flex-col bg-gray-50 -space-y-1 px-5 py-3 rounded-md mb-3 '>
<h1 className='font-bold text-xl text-gray-800'>Active users</h1>
<h2 className='text-gray-500 text-md'> Manage your organization users, assign roles and permissions </h2>
</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">
<thead className='bg-gray-100 text-gray-500 rounded-xl uppercase'> <thead className='bg-gray-100 text-gray-500 rounded-xl uppercase'>
<tr className='font-bolder text-sm'> <tr className='font-bolder text-sm'>

View file

@ -26,6 +26,7 @@
"@tiptap/react": "^2.0.0-beta.199", "@tiptap/react": "^2.0.0-beta.199",
"@tiptap/starter-kit": "^2.0.0-beta.199", "@tiptap/starter-kit": "^2.0.0-beta.199",
"avvvatars-react": "^0.4.2", "avvvatars-react": "^0.4.2",
"dayjs": "^1.11.10",
"formik": "^2.2.9", "formik": "^2.2.9",
"framer-motion": "^10.16.1", "framer-motion": "^10.16.1",
"lowlight": "^3.0.0", "lowlight": "^3.0.0",

View file

@ -1,5 +1,4 @@
import { getAPIUrl } from "@services/config/config"; import { getAPIUrl } from "@services/config/config";
import { NextApiRequestCookies } from "next/dist/server/api-utils";
interface LoginAndGetTokenResponse { interface LoginAndGetTokenResponse {
access_token: "string"; access_token: "string";
@ -139,3 +138,18 @@ export async function signup(body: NewAccountBody): Promise<any> {
return res; return res;
} }
export async function signUpWithInviteCode(body: NewAccountBody,invite_code:string): Promise<any> {
const HeadersConfig = new Headers({ "Content-Type": "application/json" });
const requestOptions: any = {
method: "POST",
headers: HeadersConfig,
body: JSON.stringify(body),
redirect: "follow",
};
const res = await fetch(`${getAPIUrl()}users/${body.org_id}/invite/${invite_code}`, requestOptions);
return res;
}

View file

@ -46,6 +46,6 @@ export function getActivityMediaDirectory(orgUUID: string, courseId: string, act
} }
export function getOrgLogoMediaDirectory(orgUUID: string, fileId: string) { export function getOrgLogoMediaDirectory(orgUUID: string, fileId: string) {
let uri = `${getMediaUrl()}content/${orgUUID}/logos/${fileId}`; let uri = `${getMediaUrl()}content/orgs/${orgUUID}/logos/${fileId}`;
return uri; return uri;
} }

View file

@ -0,0 +1,26 @@
import { getAPIUrl } from "@services/config/config";
import { RequestBody, errorHandling, getResponseMetadata } from "@services/utils/ts/requests";
export async function createInviteCode(org_id: any) {
const result = await fetch(`${getAPIUrl()}orgs/${org_id}/invites`, RequestBody("POST", null, null));
const res = await getResponseMetadata(result);
return res;
}
export async function deleteInviteCode(org_id: any, org_invite_code_uuid: string) {
const result = await fetch(`${getAPIUrl()}orgs/${org_id}/invites/${org_invite_code_uuid}`, RequestBody("DELETE", null, null));
const res = await getResponseMetadata(result);
return res;
}
export async function changeSignupMechanism(org_id: any, signup_mechanism: string) {
const result = await fetch(`${getAPIUrl()}orgs/${org_id}/signup_mechanism?signup_mechanism=${signup_mechanism}`, RequestBody("PUT", null, null));
const res = await getResponseMetadata(result);
return res;
}
export async function validateInviteCode(org_id: any, invite_code: string) {
const result = await fetch(`${getAPIUrl()}orgs/${org_id}/invites/code/${invite_code}`, RequestBody("GET", null, null));
const res = await getResponseMetadata(result);
return res;
}

7
pnpm-lock.yaml generated
View file

@ -71,6 +71,9 @@ importers:
avvvatars-react: avvvatars-react:
specifier: ^0.4.2 specifier: ^0.4.2
version: 0.4.2(csstype@3.1.2)(react-dom@18.2.0)(react@18.2.0) version: 0.4.2(csstype@3.1.2)(react-dom@18.2.0)(react@18.2.0)
dayjs:
specifier: ^1.11.10
version: 1.11.10
formik: formik:
specifier: ^2.2.9 specifier: ^2.2.9
version: 2.4.5(react@18.2.0) version: 2.4.5(react@18.2.0)
@ -3548,6 +3551,10 @@ packages:
resolution: {integrity: sha512-4FbVrHDwfOASx7uQVxeiCTo7ggSdYZbqs8lH+WU6ViypPlDbe9y6IP5VVUDQBv9DcnyaiPT5XT0UWHgJ64zLeQ==} resolution: {integrity: sha512-4FbVrHDwfOASx7uQVxeiCTo7ggSdYZbqs8lH+WU6ViypPlDbe9y6IP5VVUDQBv9DcnyaiPT5XT0UWHgJ64zLeQ==}
dev: false dev: false
/dayjs@1.11.10:
resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==}
dev: false
/debug@2.6.9: /debug@2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies: peerDependencies: