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 ( + +
+
+
+ + + +
+
+
+ +
+ {org?.logo_image ? ( + Learnhouse + ) : ( + + )} +
+
{org?.name}
+
+
+
+
+
+

Forgot Password

+

+ Enter your email address and we will send you a link to reset your + password +

+ + {error && ( +
+ +
{error}
+
+ )} + {message && ( +
+ +
{message}
+
+ )} + + + + + + + +
+ + + +
+
+ +
+
+
+ ) +} + +export default ForgotPasswordClient \ No newline at end of file diff --git a/apps/web/app/orgs/[orgslug]/forgot/page.tsx b/apps/web/app/orgs/[orgslug]/forgot/page.tsx new file mode 100644 index 00000000..bbcc0ad9 --- /dev/null +++ b/apps/web/app/orgs/[orgslug]/forgot/page.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import ForgotPasswordClient from './forgot' +import { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'LearnHouse - Forgot Password', +} + +function ForgotPasswordPage() { + return ( + <> + + + ) +} + +export default ForgotPasswordPage \ No newline at end of file diff --git a/apps/web/app/orgs/[orgslug]/login/login.tsx b/apps/web/app/orgs/[orgslug]/login/login.tsx index 4bfb1c35..603c93a4 100644 --- a/apps/web/app/orgs/[orgslug]/login/login.tsx +++ b/apps/web/app/orgs/[orgslug]/login/login.tsx @@ -156,6 +156,15 @@ const LoginClient = (props: LoginClientProps) => { /> +
+ + Forgot password? + +
diff --git a/apps/web/app/orgs/[orgslug]/reset/page.tsx b/apps/web/app/orgs/[orgslug]/reset/page.tsx new file mode 100644 index 00000000..2b206e0b --- /dev/null +++ b/apps/web/app/orgs/[orgslug]/reset/page.tsx @@ -0,0 +1,15 @@ +import { Metadata } from 'next' +import React from 'react' +import ResetPasswordClient from './reset' + +export const metadata: Metadata = { + title: 'LearnHouse - Reset Password', +} + +function ResetPasswordPage() { + return ( + + ) +} + +export default ResetPasswordPage \ No newline at end of file diff --git a/apps/web/app/orgs/[orgslug]/reset/reset.tsx b/apps/web/app/orgs/[orgslug]/reset/reset.tsx new file mode 100644 index 00000000..f29d9a36 --- /dev/null +++ b/apps/web/app/orgs/[orgslug]/reset/reset.tsx @@ -0,0 +1,220 @@ +'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, useSearchParams } from 'next/navigation' +import { useFormik } from 'formik' +import { resetPassword, 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' + } + + if (!values.new_password) { + errors.new_password = 'Required' + } + + if (!values.confirm_password) { + errors.confirm_password = 'Required' + } + + if (values.new_password !== values.confirm_password) { + errors.confirm_password = 'Passwords do not match' + } + + if (!values.reset_code) { + errors.reset_code = 'Required' + } + return errors +} + +function ResetPasswordClient() { + const org = useOrg() as any; + const [isSubmitting, setIsSubmitting] = React.useState(false) + const searchParams = useSearchParams() + const reset_code = searchParams.get('resetCode') || '' + const email = searchParams.get('email') || '' + const router = useRouter() + const [error, setError] = React.useState('') + const [message, setMessage] = React.useState('') + + const formik = useFormik({ + initialValues: { + email: email, + new_password: '', + confirm_password: '', + reset_code: reset_code + }, + validate, + enableReinitialize: true, + onSubmit: async (values) => { + setIsSubmitting(true) + let res = await resetPassword(values.email, values.new_password, org?.id, values.reset_code) + if (res.status == 200) { + setMessage(res.data + ', please login') + setIsSubmitting(false) + } else { + setError(res.data.detail) + setIsSubmitting(false) + } + + }, + }) + return ( + +
+
+
+ + + +
+
+
+ +
+ {org?.logo_image ? ( + Learnhouse + ) : ( + + )} +
+
{org?.name}
+
+
+
+
+
+

Reset Password

+

+ Enter your email and reset code to reset your password +

+ + {error && ( +
+ +
{error}
+
+ )} + {message && ( +
+ +
{message}
+
+ )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+ +
+
+
+ ) +} + +export default ResetPasswordClient \ No newline at end of file diff --git a/apps/web/services/auth/auth.ts b/apps/web/services/auth/auth.ts index f18d6002..bb3e6ef0 100644 --- a/apps/web/services/auth/auth.ts +++ b/apps/web/services/auth/auth.ts @@ -1,4 +1,5 @@ import { getAPIUrl } from '@services/config/config' +import { RequestBody, getResponseMetadata } from '@services/utils/ts/requests' interface LoginAndGetTokenResponse { access_token: 'string' @@ -36,6 +37,29 @@ export async function loginAndGetToken( return response } +export async function sendResetLink(email: string, org_id: number) { + const result = await fetch( + `${getAPIUrl()}users/reset_password/send_reset_code/${email}?org_id=${org_id}`, + RequestBody('POST', null, null) + ) + const res = await getResponseMetadata(result) + return res +} + +export async function resetPassword( + email: string, + new_password: string, + org_id: number, + reset_code: string +) { + const result = await fetch( + `${getAPIUrl()}users/reset_password/change_password/${email}?reset_code=${reset_code}&new_password=${new_password}&org_id=${org_id}`, + RequestBody('POST', null, null) + ) + const res = await getResponseMetadata(result) + return res +} + export async function logout(): Promise { // Request Config diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 020429a3..61aaae72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: eslint: specifier: ^8.51.0 version: 8.51.0 - husky: - specifier: ^9.0.10 - version: 9.0.10 prettier: specifier: ^3.0.3 version: 3.0.3 @@ -4431,12 +4428,6 @@ packages: - supports-color dev: false - /husky@9.0.10: - resolution: {integrity: sha512-TQGNknoiy6bURzIO77pPRu+XHi6zI7T93rX+QnJsoYFf3xdjKOur+IlfqzJGMHIK/wXrLg+GsvMs8Op7vI2jVA==} - engines: {node: '>=18'} - hasBin: true - dev: true - /ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} dev: false