Merge pull request #153 from learnhouse/feat/more-email-features

Email password resetting
This commit is contained in:
Badr B 2024-03-19 21:55:50 +01:00 committed by GitHub
commit a124cde229
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 780 additions and 40 deletions

View file

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

View file

@ -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(
*,

View file

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

View file

@ -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"""
<html>
<body>
<p>Hello {user.username}</p>
<p>Welcome to LearnHouse! , get started by creating your own organization or join a one.</p>
<p>Need some help to get started ? <a href="https://learn.learnhouse.io">LearnHouse Academy</a></p>
</body>
</html>
""",
)
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"""
<html>
<body>
<p>Hello {user.username}</p>
<p>Click <a href="https://{organization.slug}.learnhouse.io/reset?email={email}&resetCode={generated_reset_code}">here</a> to reset your password.</p>
</body>
</html>
""",
)

View file

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

View file

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

View file

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

View file

@ -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 (
<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="login-topbar m-10">
<Link prefetch href={getUriWithOrg(org?.slug, '/')}>
<Image
quality={100}
width={30}
height={30}
src={learnhouseIcon}
alt=""
/>
</Link>
</div>
<div className="ml-10 h-4/6 flex flex-row text-white">
<div className="m-auto flex space-x-4 items-center flex-wrap">
<div className="shadow-[0px_4px_16px_rgba(0,0,0,0.02)]">
{org?.logo_image ? (
<img
src={`${getOrgLogoMediaDirectory(
org?.org_uuid,
org?.logo_image
)}`}
alt="Learnhouse"
style={{ width: 'auto', height: 70 }}
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=""
/>
)}
</div>
<div className="font-bold text-xl">{org?.name}</div>
</div>
</div>
</div>
<div className="left-login-part bg-white flex flex-row">
<div className="login-form m-auto w-72">
<h1 className="text-2xl font-bold mb-4">Forgot Password</h1>
<p className="text-sm mb-4">
Enter your email address and we will send you a link to reset your
password
</p>
{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 justify-center bg-green-200 rounded-md text-green-950 space-x-2 items-center p-4 transition-all shadow-sm">
<Info size={18} />
<div className="font-bold text-sm">{message}</div>
</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>
<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...' : 'Send Reset Link'}
</button>
</Form.Submit>
</div>
</FormLayout>
</div>
</div>
</div>
)
}
export default ForgotPasswordClient

View file

@ -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 (
<>
<ForgotPasswordClient />
</>
)
}
export default ForgotPasswordPage

View file

@ -156,6 +156,15 @@ const LoginClient = (props: LoginClientProps) => {
/>
</Form.Control>
</FormField>
<div>
<Link
href={getUriWithOrg(props.org.slug, '/forgot')}
passHref
className="text-xs text-gray-500 hover:underline"
>
Forgot password?
</Link>
</div>
<div className="flex py-4">
<Form.Submit asChild>

View file

@ -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 (
<ResetPasswordClient />
)
}
export default ResetPasswordPage

View file

@ -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 (
<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="login-topbar m-10">
<Link prefetch href={getUriWithOrg(org?.slug, '/')}>
<Image
quality={100}
width={30}
height={30}
src={learnhouseIcon}
alt=""
/>
</Link>
</div>
<div className="ml-10 h-4/6 flex flex-row text-white">
<div className="m-auto flex space-x-4 items-center flex-wrap">
<div className="shadow-[0px_4px_16px_rgba(0,0,0,0.02)]">
{org?.logo_image ? (
<img
src={`${getOrgLogoMediaDirectory(
org?.org_uuid,
org?.logo_image
)}`}
alt="Learnhouse"
style={{ width: 'auto', height: 70 }}
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=""
/>
)}
</div>
<div className="font-bold text-xl">{org?.name}</div>
</div>
</div>
</div>
<div className="left-login-part bg-white flex flex-row">
<div className="login-form m-auto w-72">
<h1 className="text-2xl font-bold mb-4">Reset Password</h1>
<p className="text-sm mb-4">
Enter your email and reset code to reset your password
</p>
{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 justify-center bg-green-200 rounded-md text-green-950 space-x-2 items-center p-4 transition-all shadow-sm">
<Info size={18} />
<div className="font-bold text-sm">{message}</div>
</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"
/>
</Form.Control>
</FormField>
<FormField name="reset_code">
<FormLabelAndMessage
label="Reset Code"
message={formik.errors.reset_code}
/>
<Form.Control asChild>
<Input
onChange={formik.handleChange}
value={formik.values.reset_code}
type="text"
/>
</Form.Control>
</FormField>
<FormField name="new_password">
<FormLabelAndMessage
label="New Password"
message={formik.errors.new_password}
/>
<Form.Control asChild>
<Input
onChange={formik.handleChange}
value={formik.values.new_password}
type="password"
/>
</Form.Control>
</FormField>
<FormField name="confirm_password">
<FormLabelAndMessage
label="Confirm Password"
message={formik.errors.confirm_password}
/>
<Form.Control asChild>
<Input
onChange={formik.handleChange}
value={formik.values.confirm_password}
type="password"
/>
</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...' : 'Change Password'}
</button>
</Form.Submit>
</div>
</FormLayout>
</div>
</div>
</div>
)
}
export default ResetPasswordClient

View file

@ -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<any> {
// Request Config

9
pnpm-lock.yaml generated
View file

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