diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index 52ec7706..747a1f07 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -1,43 +1,42 @@ [tool.ruff] -ignore = ["E501", "E712"] +lint.ignore = ["E501", "E712"] [tool.poetry] -name = "learnhouse-api" -version = "0.1.0" -description = "Learnhouse Backend" authors = ["Badr B. (swve)"] +description = "Learnhouse Backend" +name = "learnhouse-api" readme = "README.md" +version = "0.1.0" [tool.poetry.dependencies] -python = "^3.12" -fastapi = "0.109.2" -pydantic = {version = ">=1.8.0,<2.0.0", extras = ["email"]} -sqlmodel = "0.0.10" -uvicorn = "0.27.1" -psycopg2 = "^2.9.9" -python-multipart = "^0.0.7" boto3 = "^1.34.17" botocore = "^1.34.17" -python-jose = "^3.3.0" -passlib = "^1.7.4" -fastapi-jwt-auth = "^0.5.0" -pytest = "^7.4.4" -httpx = "^0.26.0" -faker = "^22.2.0" -requests = "^2.31.0" -pyyaml = "^6.0.1" -sentry-sdk = {extras = ["fastapi"], version = "^1.39.2"} -langchain = "0.1.0" -tiktoken = "^0.5.2" -openai = "^1.7.1" chromadb = "^0.4.22" -python-dotenv = "^1.0.0" -redis = "^5.0.1" +faker = "^22.2.0" +fastapi = "0.109.2" +fastapi-jwt-auth = "^0.5.0" +httpx = "^0.26.0" +langchain = "0.1.0" langchain-community = "^0.0.11" langchain-openai = "^0.0.2.post1" +openai = "^1.7.1" +passlib = "^1.7.4" +psycopg2 = "^2.9.9" +pydantic = {version = ">=1.8.0,<2.0.0", extras = ["email"]} +pytest = "^7.4.4" +python = "^3.12" +python-dotenv = "^1.0.0" +python-jose = "^3.3.0" +python-multipart = "^0.0.7" +pyyaml = "^6.0.1" +redis = "^5.0.1" +requests = "^2.31.0" resend = "^0.7.2" - +sentry-sdk = {extras = ["fastapi"], version = "^1.39.2"} +sqlmodel = "0.0.10" +tiktoken = "^0.5.2" +uvicorn = "0.27.1" [build-system] -requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" +requires = ["poetry-core"] diff --git a/apps/api/src/routers/users.py b/apps/api/src/routers/users.py index 55365db2..e226f6b6 100644 --- a/apps/api/src/routers/users.py +++ b/apps/api/src/routers/users.py @@ -1,6 +1,11 @@ from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile +from pydantic import EmailStr from sqlmodel import Session +from src.services.users.password_reset import ( + change_password_with_reset_code, + send_reset_password_code, +) from src.services.orgs.orgs import get_org_join_mechanism from src.security.auth import get_current_user from src.core.events.database import get_db_session @@ -210,6 +215,42 @@ async def api_update_user_password( return await update_user_password(request, db_session, current_user, user_id, form) +@router.post("/reset_password/change_password/{email}", tags=["users"]) +async def api_change_password_with_reset_code( + *, + request: Request, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), + new_password: str, + email: EmailStr, + org_id: int, + reset_code: str, +): + """ + Update User Password with reset code + """ + return await change_password_with_reset_code( + request, db_session, current_user, new_password, org_id, email, reset_code + ) + + +@router.post("/reset_password/send_reset_code/{email}", tags=["users"]) +async def api_send_password_reset_email( + *, + request: Request, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), + email: EmailStr, + org_id: int, +): + """ + Update User Password + """ + return await send_reset_password_code( + request, db_session, current_user, org_id, email + ) + + @router.delete("/user_id/{user_id}", tags=["users"]) async def api_delete_user( *, diff --git a/apps/api/src/services/orgs/invites.py b/apps/api/src/services/orgs/invites.py index 8d68c0ce..cc5aae57 100644 --- a/apps/api/src/services/orgs/invites.py +++ b/apps/api/src/services/orgs/invites.py @@ -2,6 +2,7 @@ import json import random import string import uuid +from pydantic import EmailStr import redis from datetime import datetime, timedelta from sqlmodel import Session, select @@ -139,7 +140,7 @@ async def get_invite_codes( for invite_code in invite_codes: invite_code = r.get(invite_code) - invite_code = json.loads(invite_code) + invite_code = json.loads(invite_code) # type: ignore invite_codes_list.append(invite_code) return invite_codes_list @@ -258,7 +259,7 @@ def send_invite_email( org: OrganizationRead, invite_code_uuid: str, user: UserRead, - email: str, + email: EmailStr, ): LH_CONFIG = get_learnhouse_config() redis_conn_string = LH_CONFIG.redis_config.redis_connection_string @@ -284,7 +285,7 @@ def send_invite_email( # Send email if invite: invite = r.get(invite[0]) - invite = json.loads(invite) + invite = json.loads(invite) # type: ignore # send email send_email( diff --git a/apps/api/src/services/users/emails.py b/apps/api/src/services/users/emails.py new file mode 100644 index 00000000..95ab0a15 --- /dev/null +++ b/apps/api/src/services/users/emails.py @@ -0,0 +1,46 @@ +from pydantic import EmailStr +from src.db.organizations import OrganizationRead +from src.db.users import UserRead +from src.services.email.utils import send_email + + +def send_account_creation_email( + user: UserRead, + email: EmailStr, +): + # send email + return send_email( + to=email, + subject=f"Welcome to LearnHouse, {user.username}!", + body=f""" + +
+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
+ + +""", + ) + + +def send_password_reset_email( + generated_reset_code: str, + user: UserRead, + organization: OrganizationRead, + email: EmailStr, +): + + # send email + return send_email( + to=email, + subject="Reset your password", + body=f""" + + +Hello {user.username}
+Click here to reset your password.
+ + +""", + ) diff --git a/apps/api/src/services/users/password_reset.py b/apps/api/src/services/users/password_reset.py new file mode 100644 index 00000000..16b15e5a --- /dev/null +++ b/apps/api/src/services/users/password_reset.py @@ -0,0 +1,202 @@ +from datetime import datetime +import json +import random +import redis +import string +import uuid +from fastapi import HTTPException, Request +from pydantic import EmailStr +from sqlmodel import Session, select +from src.db.organizations import Organization, OrganizationRead +from src.security.security import security_hash_password +from config.config import get_learnhouse_config +from src.services.users.emails import ( + send_password_reset_email, +) +from src.db.users import ( + AnonymousUser, + PublicUser, + User, + UserRead, +) + + +async def send_reset_password_code( + request: Request, + db_session: Session, + current_user: PublicUser | AnonymousUser, + org_id: int, + email: EmailStr, +): + # Get user + statement = select(User).where(User.email == email) + user = db_session.exec(statement).first() + + if not user: + raise HTTPException( + status_code=400, + detail="User does not exist", + ) + + # Get org + statement = select(Organization).where(Organization.id == org_id) + org = db_session.exec(statement).first() + + if not org: + raise HTTPException( + status_code=400, + detail="Organization not found", + ) + + # 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", + ) + + # 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", + ) + + # Generate reset 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_reset_code = generate_code() + reset_email_invite_uuid = f"reset_email_invite_code_{uuid.uuid4()}" + + ttl = int(datetime.now().timestamp()) + 60 * 60 * 1 # 1 hour + + resetCodeObject = { + "reset_code": generated_reset_code, + "reset_email_invite_uuid": reset_email_invite_uuid, + "reset_code_expires": ttl, + "reset_code_type": "signup", + "created_at": datetime.now().isoformat(), + "created_by": user.user_uuid, + "org_uuid": org.org_uuid, + } + + r.set( + f"{reset_email_invite_uuid}:user:{user.user_uuid}:org:{org.org_uuid}:code:{generated_reset_code}", + json.dumps(resetCodeObject), + ex=ttl, + ) + + user = UserRead.from_orm(user) + + org = OrganizationRead.from_orm(org) + + # Send reset code via email + isEmailSent = send_password_reset_email( + generated_reset_code=generated_reset_code, + user=user, + organization=org, + email=user.email, + ) + + if not isEmailSent: + raise HTTPException( + status_code=500, + detail="Issue with sending reset code", + ) + + return "Reset code sent" + + +async def change_password_with_reset_code( + request: Request, + db_session: Session, + current_user: PublicUser | AnonymousUser, + new_password: str, + org_id: int, + email: EmailStr, + reset_code: str, +): + # Get user + statement = select(User).where(User.email == email) + user = db_session.exec(statement).first() + + if not user: + raise HTTPException( + status_code=400, + detail="User does not exist", + ) + + # Get org + statement = select(Organization).where(Organization.id == org_id) + org = db_session.exec(statement).first() + + if not org: + raise HTTPException( + status_code=400, + detail="Organization not found", + ) + + # 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", + ) + + # 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 reset code + reset_code_key = f"*:user:{user.user_uuid}:org:{org.org_uuid}:code:{reset_code}" + keys = r.keys(reset_code_key) + + if not keys: + raise HTTPException( + status_code=400, + detail="Reset code not found", + ) + + # Get reset code object + reset_code_value = r.get(keys[0]) + + if reset_code_value is None: + raise HTTPException( + status_code=400, + detail="Reset code value not found", + ) + reset_code_object = json.loads(reset_code_value) + + # Check if reset code is expired + if reset_code_object["reset_code_expires"] < int(datetime.now().timestamp()): + raise HTTPException( + status_code=400, + detail="Reset code expired", + ) + + # Change password + user.password = await security_hash_password(new_password) + db_session.add(user) + + db_session.commit() + db_session.refresh(user) + + # Delete reset code + r.delete(keys[0]) + + return "Password changed" diff --git a/apps/api/src/services/users/users.py b/apps/api/src/services/users/users.py index 279a9aec..88d5eb09 100644 --- a/apps/api/src/services/users/users.py +++ b/apps/api/src/services/users/users.py @@ -3,6 +3,9 @@ from typing import Literal from uuid import uuid4 from fastapi import HTTPException, Request, UploadFile, status from sqlmodel import Session, select +from src.services.users.emails import ( + send_account_creation_email, +) from src.services.orgs.invites import get_invite_code from src.services.users.avatars import upload_avatar from src.db.roles import Role, RoleRead @@ -102,6 +105,12 @@ async def create_user( user = UserRead.from_orm(user) + # Send Account creation email + send_account_creation_email( + user=user, + email=user.email, + ) + return user @@ -182,6 +191,12 @@ async def create_user_without_org( user = UserRead.from_orm(user) + # Send Account creation email + send_account_creation_email( + user=user, + email=user.email, + ) + return user @@ -331,7 +346,6 @@ async def update_user_password( return user - async def read_user_by_id( request: Request, db_session: Session, diff --git a/apps/web/app/orgs/[orgslug]/dash/layout.tsx b/apps/web/app/orgs/[orgslug]/dash/layout.tsx index 7c847dcd..ad553272 100644 --- a/apps/web/app/orgs/[orgslug]/dash/layout.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/layout.tsx @@ -1,8 +1,13 @@ import SessionProvider from '@components/Contexts/SessionContext' import LeftMenu from '@components/Dashboard/UI/LeftMenu' import AdminAuthorization from '@components/Security/AdminAuthorization' +import { Metadata } from 'next' import React from 'react' +export const metadata: Metadata = { + title: 'LearnHouse Dashboard', +} + function DashboardLayout({ children, params, diff --git a/apps/web/app/orgs/[orgslug]/forgot/forgot.tsx b/apps/web/app/orgs/[orgslug]/forgot/forgot.tsx new file mode 100644 index 00000000..8b3cb7c2 --- /dev/null +++ b/apps/web/app/orgs/[orgslug]/forgot/forgot.tsx @@ -0,0 +1,156 @@ +'use client' +import Image from 'next/image' +import React from 'react' +import learnhouseIcon from 'public/learnhouse_bigicon_1.png' +import FormLayout, { + FormField, + FormLabelAndMessage, + Input, +} from '@components/StyledElements/Form/Form' +import * as Form from '@radix-ui/react-form' +import { getOrgLogoMediaDirectory } from '@services/media/media' +import { AlertTriangle, Info } from 'lucide-react' +import Link from 'next/link' +import { getUriWithOrg } from '@services/config/config' +import { useOrg } from '@components/Contexts/OrgContext' +import { useRouter } from 'next/navigation' +import { useFormik } from 'formik' +import { sendResetLink } from '@services/auth/auth' + +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' + } + + + return errors +} + +function ForgotPasswordClient() { + const org = useOrg() as any; + const [isSubmitting, setIsSubmitting] = React.useState(false) + const router = useRouter() + const [error, setError] = React.useState('') + const [message, setMessage] = React.useState('') + + const formik = useFormik({ + initialValues: { + email: '' + }, + validate, + onSubmit: async (values) => { + setIsSubmitting(true) + let res = await sendResetLink(values.email, org?.id) + if (res.status == 200) { + setMessage(res.data + ', please check your email') + setIsSubmitting(false) + } else { + setError(res.data.detail) + setIsSubmitting(false) + } + }, + }) + return ( + ++ Enter your email address and we will send you a link to reset your + password +
+ + {error && ( ++ Enter your email and reset code to reset your password +
+ + {error && ( +