diff --git a/.npmrc b/.npmrc index 4343c8ca..90ed8716 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ -shared-workspace-lockfile=false \ No newline at end of file +shared-workspace-lockfile=false +package-manager-strict=false \ No newline at end of file diff --git a/apps/api/src/routers/auth.py b/apps/api/src/routers/auth.py index 535ebe74..f13bc033 100644 --- a/apps/api/src/routers/auth.py +++ b/apps/api/src/routers/auth.py @@ -1,11 +1,14 @@ from datetime import timedelta +from typing import Literal, Optional from fastapi import Depends, APIRouter, HTTPException, Response, status, Request from fastapi.security import OAuth2PasswordRequestForm +from pydantic import BaseModel, EmailStr from sqlmodel import Session -from src.db.users import UserRead +from src.db.users import AnonymousUser, UserRead from src.core.events.database import get_db_session from config.config import get_learnhouse_config -from src.security.auth import AuthJWT, authenticate_user +from src.security.auth import AuthJWT, authenticate_user, get_current_user +from src.services.auth.utils import signWithGoogle router = APIRouter() @@ -74,6 +77,58 @@ async def login( return result +class ThirdPartyLogin(BaseModel): + email: EmailStr + provider: Literal["google"] + access_token: str + + +@router.post("/oauth") +async def third_party_login( + request: Request, + response: Response, + body: ThirdPartyLogin, + org_id: Optional[int] = None, + current_user: AnonymousUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), + Authorize: AuthJWT = Depends(), +): + # Google + if body.provider == "google": + + user = await signWithGoogle( + request, body.access_token, body.email, org_id, current_user, db_session + ) + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect Email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + access_token = Authorize.create_access_token(subject=user.email) + refresh_token = Authorize.create_refresh_token(subject=user.email) + Authorize.set_refresh_cookies(refresh_token) + + # set cookies using fastapi + response.set_cookie( + key="access_token_cookie", + value=access_token, + httponly=False, + domain=get_learnhouse_config().hosting_config.cookie_config.domain, + expires=int(timedelta(hours=8).total_seconds()), + ) + + user = UserRead.model_validate(user) + + result = { + "user": user, + "tokens": {"access_token": access_token, "refresh_token": refresh_token}, + } + return result + + @router.delete("/logout") def logout(Authorize: AuthJWT = Depends()): """ diff --git a/apps/api/src/routers/orgs.py b/apps/api/src/routers/orgs.py index cd7f7d9b..ad345649 100644 --- a/apps/api/src/routers/orgs.py +++ b/apps/api/src/routers/orgs.py @@ -8,6 +8,7 @@ from src.services.orgs.invites import ( get_invite_code, get_invite_codes, ) +from src.services.orgs.join import JoinOrg, join_org from src.services.orgs.users import ( get_list_of_invited_users, get_organization_users, @@ -99,6 +100,19 @@ async def api_get_org_users( return await get_organization_users(request, org_id, db_session, current_user) +@router.post("/join") +async def api_join_an_org( + request: Request, + args: JoinOrg, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + """ + Get single Org by ID + """ + return await join_org(request, args, current_user, db_session) + + @router.put("/{org_id}/users/{user_id}/role/{role_uuid}") async def api_update_user_role( request: Request, diff --git a/apps/api/src/routers/users.py b/apps/api/src/routers/users.py index 5dc621f3..98cb25aa 100644 --- a/apps/api/src/routers/users.py +++ b/apps/api/src/routers/users.py @@ -85,7 +85,6 @@ async def api_create_user_with_orgid( """ 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 ( diff --git a/apps/api/src/services/auth/utils.py b/apps/api/src/services/auth/utils.py new file mode 100644 index 00000000..aa6b0d17 --- /dev/null +++ b/apps/api/src/services/auth/utils.py @@ -0,0 +1,70 @@ +import random +from typing import Optional +from fastapi import Depends, HTTPException, Request +import httpx +from sqlmodel import Session, select +from src.core.events.database import get_db_session +from src.db.users import User, UserCreate, UserRead +from src.security.auth import get_current_user +from src.services.users.users import create_user, create_user_without_org + + +async def get_google_user_info(access_token: str): + url = "https://www.googleapis.com/oauth2/v3/userinfo" + headers = {"Authorization": f"Bearer {access_token}"} + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers) + + if response.status_code != 200: + raise HTTPException( + status_code=response.status_code, + detail="Failed to fetch user info from Google", + ) + + return response.json() + + +async def signWithGoogle( + request: Request, + access_token: str, + email: str, + org_id: Optional[int] = None, + current_user=Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + # Google + google_user = await get_google_user_info(access_token) + + user = db_session.exec( + select(User).where(User.email == google_user["email"]) + ).first() + + if not user: + username = ( + google_user["given_name"] + + google_user["family_name"] + + str(random.randint(10, 99)) + ) + user_object = UserCreate( + email=google_user["email"], + username=username, + password="", + first_name=google_user["given_name"], + last_name=google_user["family_name"], + avatar_image=google_user["picture"], + ) + + if org_id is not None: + user = await create_user( + request, db_session, current_user, user_object, org_id + ) + + return user + else: + user = await create_user_without_org( + request, db_session, current_user, user_object + ) + + return user + + return UserRead.model_validate(user) diff --git a/apps/api/src/services/orgs/join.py b/apps/api/src/services/orgs/join.py new file mode 100644 index 00000000..177cc04e --- /dev/null +++ b/apps/api/src/services/orgs/join.py @@ -0,0 +1,119 @@ +from datetime import datetime +from typing import Optional +from fastapi import HTTPException, Request +from pydantic import BaseModel +from sqlmodel import Session, select +from src.db.organizations import Organization +from src.db.user_organizations import UserOrganization +from src.db.users import AnonymousUser, PublicUser, User +from src.services.orgs.invites import get_invite_code +from src.services.orgs.orgs import get_org_join_mechanism + + +class JoinOrg(BaseModel): + org_id: int + user_id: str + invite_code: Optional[str] + + +async def join_org( + request: Request, + args: JoinOrg, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + statement = select(Organization).where(Organization.id == args.org_id) + result = db_session.exec(statement) + + org = result.first() + + if not org: + raise HTTPException( + status_code=404, + detail="Organization not found", + ) + + join_method = await get_org_join_mechanism( + request, args.org_id, current_user, db_session + ) + + # Get User + statement = select(User).where(User.id == args.user_id) + result = db_session.exec(statement) + + user = result.first() + + # Check if User isn't already part of the org + statement = select(UserOrganization).where( + UserOrganization.user_id == args.user_id, UserOrganization.org_id == args.org_id + ) + result = db_session.exec(statement) + + userorg = result.first() + + if userorg: + raise HTTPException( + status_code=400, detail="User is already part of that organization" + ) + + if join_method == "inviteOnly" and user and org and args.invite_code: + if user.id is not None and org.id is not None: + + # Check if invite code exists + inviteCode = await get_invite_code( + request, org.id, args.invite_code, current_user, db_session + ) + + if not inviteCode: + raise HTTPException( + status_code=400, + detail="Invite code is incorrect", + ) + + # Link user and organization + user_organization = UserOrganization( + user_id=user.id, + org_id=org.id, + role_id=3, + creation_date=str(datetime.now()), + update_date=str(datetime.now()), + ) + + db_session.add(user_organization) + db_session.commit() + + return "Great, You're part of the Organization" + + else: + raise HTTPException( + status_code=403, + detail="Something wrong, try later.", + ) + + if join_method == "open" and user and org: + if user.id is not None and org.id is not None: + # Link user and organization + user_organization = UserOrganization( + user_id=user.id, + org_id=org.id, + role_id=3, + creation_date=str(datetime.now()), + update_date=str(datetime.now()), + ) + + db_session.add(user_organization) + db_session.commit() + + return "Great, You're part of the Organization" + + else: + raise HTTPException( + status_code=403, + detail="Something wrong, try later.", + ) + + else: + raise HTTPException( + status_code=403, + detail="Something wrong, try later.", + ) diff --git a/apps/api/src/services/orgs/orgs.py b/apps/api/src/services/orgs/orgs.py index bd96d22b..d7923ae9 100644 --- a/apps/api/src/services/orgs/orgs.py +++ b/apps/api/src/services/orgs/orgs.py @@ -436,8 +436,7 @@ async def get_orgs_by_user( orgs = result.all() - return orgs - + return orgs #type:ignore # Config related async def update_org_signup_mechanism( diff --git a/apps/api/src/services/users/emails.py b/apps/api/src/services/users/emails.py index 95ab0a15..6e20e704 100644 --- a/apps/api/src/services/users/emails.py +++ b/apps/api/src/services/users/emails.py @@ -17,7 +17,7 @@ def send_account_creation_email(
Hello {user.username}
Welcome to LearnHouse! , get started by creating your own organization or join a one.
-Need some help to get started ? LearnHouse Academy
+Need some help to get started ? LearnHouse Academy