diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 29ab464b..06fb9a11 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -14,7 +14,8 @@ "dbaeumer.vscode-eslint", "esbenp.prettier-vscode", "ms-python.isort", - "redhat.vscode-yaml" + "redhat.vscode-yaml", + "bradlc.vscode-tailwindcss" ], "settings": { "[python]": { diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100644 index 77492786..00000000 --- a/.husky/pre-commit +++ /dev/null @@ -1 +0,0 @@ -pnpm run format && cd ./apps/web && pnpm run lint:fix \ No newline at end of file diff --git a/apps/api/config/config.py b/apps/api/config/config.py index ddf6a637..ef10272a 100644 --- a/apps/api/config/config.py +++ b/apps/api/config/config.py @@ -51,12 +51,19 @@ class HostingConfig(BaseModel): content_delivery: ContentDeliveryConfig +class MailingConfig(BaseModel): + resend_api_key: str + system_email_address: str + + class DatabaseConfig(BaseModel): sql_connection_string: Optional[str] + class RedisConfig(BaseModel): redis_connection_string: Optional[str] + class LearnHouseConfig(BaseModel): site_name: str site_description: str @@ -67,6 +74,7 @@ class LearnHouseConfig(BaseModel): redis_config: RedisConfig security_config: SecurityConfig ai_config: AIConfig + mailing_config: MailingConfig def get_learnhouse_config() -> LearnHouseConfig: @@ -113,6 +121,7 @@ def get_learnhouse_config() -> LearnHouseConfig: env_use_default_org = os.environ.get("LEARNHOUSE_USE_DEFAULT_ORG") env_allowed_origins = os.environ.get("LEARNHOUSE_ALLOWED_ORIGINS") env_cookie_domain = os.environ.get("LEARNHOUSE_COOKIE_DOMAIN") + # Allowed origins should be a comma separated string if env_allowed_origins: env_allowed_origins = env_allowed_origins.split(",") @@ -182,14 +191,6 @@ def get_learnhouse_config() -> LearnHouseConfig: ).get("sql_connection_string") - - # Redis config - env_redis_connection_string = os.environ.get("LEARNHOUSE_REDIS_CONNECTION_STRING") - redis_connection_string = env_redis_connection_string or yaml_config.get( - "redis_config", {} - ).get("redis_connection_string") - - # AI Config env_openai_api_key = os.environ.get("LEARNHOUSE_OPENAI_API_KEY") env_is_ai_enabled = os.environ.get("LEARNHOUSE_IS_AI_ENABLED") @@ -200,6 +201,22 @@ def get_learnhouse_config() -> LearnHouseConfig: "is_ai_enabled" ) + # Redis config + env_redis_connection_string = os.environ.get("LEARNHOUSE_REDIS_CONNECTION_STRING") + redis_connection_string = env_redis_connection_string or yaml_config.get( + "redis_config", {} + ).get("redis_connection_string") + + # Mailing config + env_resend_api_key = os.environ.get("LEARNHOUSE_RESEND_API_KEY") + env_system_email_address = os.environ.get("LEARNHOUSE_SYSTEM_EMAIL_ADDRESS") + resend_api_key = env_resend_api_key or yaml_config.get("mailing_config", {}).get( + "resend_api_key" + ) + system_email_address = env_system_email_address or yaml_config.get( + "mailing_config", {} + ).get("system_email_adress") + # Sentry config # check if the sentry config is provided in the YAML file sentry_config_verif = ( @@ -262,6 +279,9 @@ def get_learnhouse_config() -> LearnHouseConfig: security_config=SecurityConfig(auth_jwt_secret_key=auth_jwt_secret_key), ai_config=ai_config, redis_config=RedisConfig(redis_connection_string=redis_connection_string), + mailing_config=MailingConfig( + resend_api_key=resend_api_key, system_email_address=system_email_address + ), ) return config diff --git a/apps/api/config/config.yaml b/apps/api/config/config.yaml index 1ead112d..72aec965 100644 --- a/apps/api/config/config.yaml +++ b/apps/api/config/config.yaml @@ -24,6 +24,10 @@ hosting_config: bucket_name: "" endpoint_url: "" +mailing_config: + resend_api_key: "" + system_email_adress: "" + database_config: sql_connection_string: postgresql://learnhouse:learnhouse@db:5432/learnhouse diff --git a/apps/api/poetry.lock b/apps/api/poetry.lock index 4633e8f5..c6ffe86b 100644 --- a/apps/api/poetry.lock +++ b/apps/api/poetry.lock @@ -2510,6 +2510,20 @@ requests = ">=2.0.0" [package.extras] rsa = ["oauthlib[signedtoken] (>=3.0.0)"] +[[package]] +name = "resend" +version = "0.7.2" +description = "Resend Python SDK" +optional = false +python-versions = ">=3.7" +files = [ + {file = "resend-0.7.2-py2.py3-none-any.whl", hash = "sha256:4f16711e11b007da7f8826283af6cdc34c99bd77c1dfad92afe9466a90d06c61"}, + {file = "resend-0.7.2.tar.gz", hash = "sha256:bb10522a5ef1235b6cc2d74902df39c4863ac12b89dc48b46dd5c6f980574622"}, +] + +[package.dependencies] +requests = "2.31.0" + [[package]] name = "rsa" version = "4.9" @@ -3495,4 +3509,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "909bc8706c915f93e93d0d27bcc69db648213249ecd53e293283a1a8369e9692" +content-hash = "7443226d64e2ee6b0844e1f85d7c01b20c0bdb78e32f84875aa71ce956e141d3" diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index 66cc2bcb..52ec7706 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -35,6 +35,7 @@ python-dotenv = "^1.0.0" redis = "^5.0.1" langchain-community = "^0.0.11" langchain-openai = "^0.0.2.post1" +resend = "^0.7.2" [build-system] diff --git a/apps/api/src/core/events/database.py b/apps/api/src/core/events/database.py index b38712b2..01318298 100644 --- a/apps/api/src/core/events/database.py +++ b/apps/api/src/core/events/database.py @@ -5,7 +5,7 @@ from sqlmodel import SQLModel, Session, create_engine learnhouse_config = get_learnhouse_config() engine = create_engine( - learnhouse_config.database_config.sql_connection_string, echo=False # type: ignore + learnhouse_config.database_config.sql_connection_string, echo=False, pool_pre_ping=True # type: ignore ) SQLModel.metadata.create_all(engine) @@ -16,8 +16,6 @@ async def connect_to_db(app: FastAPI): SQLModel.metadata.create_all(engine) - - def get_db_session(): with Session(engine) as session: yield session diff --git a/apps/api/src/routers/orgs.py b/apps/api/src/routers/orgs.py index 72ce616e..80a2d944 100644 --- a/apps/api/src/routers/orgs.py +++ b/apps/api/src/routers/orgs.py @@ -174,6 +174,7 @@ async def api_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, @@ -186,7 +187,7 @@ async def api_get_invite_code( 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) + return await get_invite_code(request, org_id, invite_code, current_user, db_session) @router.delete("/{org_id}/invites/{org_invite_code_uuid}") @@ -209,14 +210,17 @@ async def api_delete_invite_code( async def api_invite_batch_users( request: Request, org_id: int, - users: str, + emails: str, + invite_code_uuid: str, current_user: PublicUser = Depends(get_current_user), db_session: Session = Depends(get_db_session), ): """ - Invite batch users + Invite batch users by emails """ - return await invite_batch_users(request, org_id, users, db_session, current_user) + return await invite_batch_users( + request, org_id, emails, invite_code_uuid, db_session, current_user + ) @router.get("/{org_id}/invites/users") @@ -231,6 +235,7 @@ async def api_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, diff --git a/apps/api/src/services/email/__init__.py b/apps/api/src/services/email/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/src/services/email/utils.py b/apps/api/src/services/email/utils.py new file mode 100644 index 00000000..872d9675 --- /dev/null +++ b/apps/api/src/services/email/utils.py @@ -0,0 +1,19 @@ +from pydantic import EmailStr +import resend +from config.config import get_learnhouse_config + + +def send_email(to: EmailStr, subject: str, body: str): + lh_config = get_learnhouse_config() + params = { + "from": "LearnHouse <" + lh_config.mailing_config.system_email_address + ">", + "to": [to], + "subject": subject, + "html": body, + } + + resend.api_key = lh_config.mailing_config.resend_api_key + email = resend.Emails.send(params) + + return email + diff --git a/apps/api/src/services/orgs/invites.py b/apps/api/src/services/orgs/invites.py index 5b2ab948..8d68c0ce 100644 --- a/apps/api/src/services/orgs/invites.py +++ b/apps/api/src/services/orgs/invites.py @@ -5,11 +5,13 @@ import uuid import redis from datetime import datetime, timedelta from sqlmodel import Session, select +from src.services.email.utils import send_email 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.users import AnonymousUser, PublicUser, UserRead from src.db.organizations import ( Organization, + OrganizationRead, ) from fastapi import HTTPException, Request @@ -142,6 +144,7 @@ async def get_invite_codes( return invite_codes_list + async def get_invite_code( request: Request, org_id: int, @@ -181,23 +184,21 @@ async def get_invite_code( 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 + 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 = r.get(invite_code[0]) # type: ignore invite_code = json.loads(invite_code) return invite_code - - + async def delete_invite_code( request: Request, @@ -251,3 +252,57 @@ async def delete_invite_code( ) return keys + + +def send_invite_email( + org: OrganizationRead, + invite_code_uuid: str, + user: UserRead, + email: str, +): + 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", + ) + + # 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 = r.keys(f"{invite_code_uuid}:org:{org.org_uuid}:code:*") # type: ignore + + # Send email + if invite: + invite = r.get(invite[0]) + invite = json.loads(invite) + + # send email + send_email( + to=email, + subject=f"You have been invited to {org.name}", + body=f""" + +
+Hello {email}
+You have been invited to {org.name} by @{user.username}. Your invite code is {invite['invite_code']}.
+Click here to sign up.
+Thank you
+ + +""", + ) + + return True + + else: + return False diff --git a/apps/api/src/services/orgs/users.py b/apps/api/src/services/orgs/users.py index 28422fb5..207508a8 100644 --- a/apps/api/src/services/orgs/users.py +++ b/apps/api/src/services/orgs/users.py @@ -5,6 +5,7 @@ import logging import redis from fastapi import HTTPException, Request from sqlmodel import Session, select +from src.services.orgs.invites import send_invite_email from config.config import get_learnhouse_config from src.services.orgs.orgs import rbac_check from src.db.roles import Role, RoleRead @@ -12,6 +13,7 @@ from src.db.users import AnonymousUser, PublicUser, User, UserRead from src.db.user_organizations import UserOrganization from src.db.organizations import ( Organization, + OrganizationRead, OrganizationUser, ) @@ -234,6 +236,7 @@ async def invite_batch_users( request: Request, org_id: int, emails: str, + invite_code_uuid: str, db_session: Session, current_user: PublicUser | AnonymousUser, ): @@ -258,6 +261,10 @@ async def invite_batch_users( detail="Organization not found", ) + # get User sender + statement = select(User).where(User.id == current_user.id) + user = db_session.exec(statement).first() + # RBAC check await rbac_check(request, org.org_uuid, current_user, "create", db_session) @@ -272,8 +279,8 @@ async def invite_batch_users( invite_list = emails.split(",") - # invitations expire after 30 days - ttl = int(timedelta(days=365).total_seconds()) + # invitations expire after 60 days + ttl = int(timedelta(days=60).total_seconds()) for email in invite_list: email = email.strip() @@ -286,11 +293,22 @@ async def invite_batch_users( # skip this user continue + org = OrganizationRead.from_orm(org) + user = UserRead.from_orm(user) + + isEmailSent = send_invite_email( + org, + invite_code_uuid, + user, + email, + ) + invited_user_object = { "email": email, "org_id": org.id, + "invite_code_uuid": invite_code_uuid, "pending": True, - "email_sent": False, + "email_sent": isEmailSent, "expires": ttl, "created_at": datetime.now().isoformat(), "created_by": current_user.user_uuid, diff --git a/apps/api/src/services/users/users.py b/apps/api/src/services/users/users.py index 8d208b32..279a9aec 100644 --- a/apps/api/src/services/users/users.py +++ b/apps/api/src/services/users/users.py @@ -104,6 +104,7 @@ async def create_user( return user + async def create_user_with_invite( request: Request, db_session: Session, @@ -112,20 +113,23 @@ async def create_user_with_invite( 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) + 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, db_session: Session, @@ -201,6 +205,32 @@ async def update_user( # RBAC check await rbac_check(request, current_user, "update", user.user_uuid, db_session) + # Verifications + + # Username + statement = select(User).where(User.username == user_object.username) + username_user = db_session.exec(statement).first() + + if username_user: + isSameUser = username_user.id == current_user.id + if not isSameUser: + raise HTTPException( + status_code=400, + detail="Username already exists", + ) + + # Email + statement = select(User).where(User.email == user_object.email) + email_user = db_session.exec(statement).first() + + if email_user: + isSameUser = email_user.id == current_user.id + if not isSameUser: + raise HTTPException( + status_code=400, + detail="Email already exists", + ) + # Update user user_data = user_object.dict(exclude_unset=True) for key, value in user_data.items(): @@ -239,7 +269,9 @@ async def update_user_avatar( # Upload thumbnail if avatar_file and avatar_file.filename: - name_in_disk = f"{user.user_uuid}_avatar_{uuid4()}.{avatar_file.filename.split('.')[-1]}" + name_in_disk = ( + f"{user.user_uuid}_avatar_{uuid4()}.{avatar_file.filename.split('.')[-1]}" + ) await upload_avatar(avatar_file, name_in_disk, user.user_uuid) # Update course 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 16bc5c5e..e5af8896 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 @@ -9,6 +9,7 @@ import { useSession } from '@components/Contexts/SessionContext' import { useOrg } from '@components/Contexts/OrgContext' import OrgUsers from '@components/Dashboard/Users/OrgUsers/OrgUsers' import OrgAccess from '@components/Dashboard/Users/OrgAccess/OrgAccess' +import OrgUsersAdd from '@components/Dashboard/Users/OrgUsersAdd/OrgUsersAdd' export type SettingsParams = { subpage: string @@ -120,6 +121,7 @@ function UsersSettingsPage({ params }: { params: SettingsParams }) { > {params.subpage == 'users' ?Invite Code
+ +| Signup Status | +Email sent | +|
|---|---|---|
| {invited_user.email} | +{invited_user.pending ? Pending : Signed } |
+ {invited_user.email_sent ? Sent : No } |
+
+
+