mirror of
https://github.com/open-webui/open-webui
synced 2025-04-10 15:45:45 +00:00
Implement Emailing, Implement Add-User Flow
This commit is contained in:
parent
7ad498bfda
commit
ff68a9ae80
@ -9,6 +9,7 @@ from open_webui.internal.db import get_db, Base
|
||||
|
||||
# Constants
|
||||
NO_COMPANY = "NO_COMPANY"
|
||||
EIGHTY_PERCENT_CREDIT_LIMIT = 4000
|
||||
|
||||
####################
|
||||
# Company DB Schema
|
||||
@ -179,9 +180,4 @@ class CompanyTable:
|
||||
company = db.query(Company).filter(Company.id == company_id).first()
|
||||
return company.credit_balance if company else None
|
||||
|
||||
def has_sufficient_credits(self, company_id: str, required_credits: int) -> bool:
|
||||
"""Check if company has sufficient credits for an operation"""
|
||||
balance = self.get_credit_balance(company_id)
|
||||
return balance is None or balance >= required_credits # None means unlimited
|
||||
|
||||
Companies = CompanyTable()
|
@ -3,6 +3,8 @@ import uuid
|
||||
import time
|
||||
import datetime
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from aiohttp import ClientSession
|
||||
|
||||
from open_webui.models.auths import (
|
||||
@ -19,6 +21,7 @@ from open_webui.models.auths import (
|
||||
UserResponse,
|
||||
)
|
||||
from beyond_the_loop.models.users import Users
|
||||
from beyond_the_loop.services.email_service import EmailService
|
||||
from beyond_the_loop.models.companies import NO_COMPANY
|
||||
|
||||
from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
|
||||
@ -550,6 +553,19 @@ async def signout(request: Request, response: Response):
|
||||
############################
|
||||
|
||||
|
||||
def generate_secure_password(length=12):
|
||||
"""Generate a secure random password."""
|
||||
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
|
||||
while True:
|
||||
password = ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||
# Check if password has at least one of each: uppercase, lowercase, digit, special char
|
||||
if (any(c.isupper() for c in password)
|
||||
and any(c.islower() for c in password)
|
||||
and any(c.isdigit() for c in password)
|
||||
and any(c in "!@#$%^&*" for c in password)):
|
||||
return password
|
||||
|
||||
|
||||
@router.post("/add", response_model=SigninResponse)
|
||||
async def add_user(form_data: AddUserForm, admin_user: Users = Depends(get_admin_user)):
|
||||
if not validate_email_format(form_data.email.lower()):
|
||||
@ -561,7 +577,10 @@ async def add_user(form_data: AddUserForm, admin_user: Users = Depends(get_admin
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
|
||||
|
||||
try:
|
||||
hashed = get_password_hash(form_data.password)
|
||||
# Generate a secure random password
|
||||
password = generate_secure_password()
|
||||
hashed = get_password_hash(password)
|
||||
|
||||
new_user = Auths.insert_new_auth(
|
||||
form_data.email.lower(),
|
||||
hashed,
|
||||
@ -572,6 +591,14 @@ async def add_user(form_data: AddUserForm, admin_user: Users = Depends(get_admin
|
||||
)
|
||||
|
||||
if new_user:
|
||||
# Send welcome email with the generated password
|
||||
email_service = EmailService()
|
||||
email_service.send_welcome_mail(
|
||||
to_email=form_data.email.lower(),
|
||||
username=form_data.name,
|
||||
password=password
|
||||
)
|
||||
|
||||
token = create_token(data={"id": new_user.id})
|
||||
return {
|
||||
"token": token,
|
||||
|
@ -19,7 +19,9 @@ from starlette.background import BackgroundTask
|
||||
from beyond_the_loop.models.models import Models
|
||||
from beyond_the_loop.models.model_message_credit_costs import ModelMessageCreditCosts
|
||||
from beyond_the_loop.models.companies import Companies
|
||||
from beyond_the_loop.models.companies import EIGHTY_PERCENT_CREDIT_LIMIT
|
||||
from beyond_the_loop.models.completions import Completions
|
||||
from beyond_the_loop.services.email_service import EmailService
|
||||
|
||||
from open_webui.config import (
|
||||
CACHE_DIR,
|
||||
@ -565,13 +567,24 @@ async def generate_chat_completion(
|
||||
# Subtract credits from the company's balance (if possible)
|
||||
model_message_credit_cost = ModelMessageCreditCosts.get_cost_by_model(model_id)
|
||||
|
||||
# Get current credit balance once
|
||||
current_balance = Companies.get_credit_balance(user.company_id)
|
||||
|
||||
# Check if company has sufficient credits
|
||||
if not Companies.has_sufficient_credits(user.company_id, model_message_credit_cost):
|
||||
if current_balance < model_message_credit_cost:
|
||||
email_service = EmailService()
|
||||
email_service.send_budget_mail_100(to_email=user.email, recipient_name=user.name)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=402, # 402 Payment Required
|
||||
detail=f"Insufficient credits. This operation requires {model_message_credit_cost} credits.",
|
||||
)
|
||||
|
||||
# Check 80% threshold
|
||||
if current_balance - model_message_credit_cost < EIGHTY_PERCENT_CREDIT_LIMIT: # If balance is less than 125% of required (which means we're below 80%)
|
||||
email_service = EmailService()
|
||||
email_service.send_budget_mail_80(to_email=user.email, recipient_name=user.name)
|
||||
|
||||
# Subtract credits from balance
|
||||
Companies.subtract_credit_balance(user.company_id, model_message_credit_cost)
|
||||
|
||||
|
117
backend/beyond_the_loop/services/email_service.py
Normal file
117
backend/beyond_the_loop/services/email_service.py
Normal file
@ -0,0 +1,117 @@
|
||||
from typing import Dict, Optional, List
|
||||
import sib_api_v3_sdk
|
||||
from sib_api_v3_sdk.rest import ApiException
|
||||
from pydantic import EmailStr
|
||||
import os
|
||||
from pathlib import Path
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
class EmailService:
|
||||
def __init__(self):
|
||||
self.configuration = sib_api_v3_sdk.Configuration()
|
||||
self.configuration.api_key['api-key'] = os.getenv('BREVO_API_KEY')
|
||||
self.api_instance = sib_api_v3_sdk.TransactionalEmailsApi(sib_api_v3_sdk.ApiClient(self.configuration))
|
||||
|
||||
# Initialize Jinja2 environment
|
||||
template_dir = Path(__file__).parent.parent / 'templates' / 'email'
|
||||
self.jinja_env = Environment(loader=FileSystemLoader(template_dir))
|
||||
|
||||
def send_welcome_mail(self, to_email: EmailStr, username: str, password: str) -> bool:
|
||||
"""Send a welcome email to newly registered users."""
|
||||
try:
|
||||
subject = "Willkommen bei Bchat!"
|
||||
sender = {"name": "Bchat", "email": os.getenv('SENDER_EMAIL', 'noreply@beyondtheloop.ai')}
|
||||
to = [{"email": to_email, "name": username}]
|
||||
|
||||
# Load and render the template
|
||||
template = self.jinja_env.get_template('welcome.html')
|
||||
html_content = template.render(
|
||||
name=username,
|
||||
password=password
|
||||
)
|
||||
|
||||
send_smtp_email = sib_api_v3_sdk.SendSmtpEmail(
|
||||
to=to,
|
||||
html_content=html_content,
|
||||
sender=sender,
|
||||
subject=subject
|
||||
)
|
||||
|
||||
self.api_instance.send_transac_email(send_smtp_email)
|
||||
return True
|
||||
|
||||
except ApiException as e:
|
||||
print(f"Exception when sending registration email: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def send_custom_email(self, to_email: EmailStr, subject: str, html_content: str,
|
||||
recipient_name: Optional[str] = None) -> bool:
|
||||
"""Send a custom email with specified content."""
|
||||
try:
|
||||
sender = {"name": "Beyond The Loop", "email": os.getenv('SENDER_EMAIL', 'noreply@beyondtheloop.com')}
|
||||
to = [{"email": to_email, "name": recipient_name or to_email}]
|
||||
|
||||
send_smtp_email = sib_api_v3_sdk.SendSmtpEmail(
|
||||
to=to,
|
||||
html_content=html_content,
|
||||
sender=sender,
|
||||
subject=subject
|
||||
)
|
||||
|
||||
self.api_instance.send_transac_email(send_smtp_email)
|
||||
return True
|
||||
|
||||
except ApiException as e:
|
||||
print(f"Exception when sending custom email: {e}")
|
||||
return False
|
||||
|
||||
def send_budget_mail_80(self, to_email: EmailStr, recipient_name: Optional[str] = None) -> bool:
|
||||
"""Send a warning email when budget reaches 80% of limit."""
|
||||
try:
|
||||
subject = "Abrechnungslimit fast erreicht"
|
||||
sender = {"name": "Beyond The Loop", "email": os.getenv('SENDER_EMAIL', 'noreply@beyondtheloop.ai')}
|
||||
to = [{"email": to_email, "name": recipient_name or to_email}]
|
||||
|
||||
# Load and render the template
|
||||
template = self.jinja_env.get_template('budget-mail-80.html')
|
||||
html_content = template.render()
|
||||
|
||||
send_smtp_email = sib_api_v3_sdk.SendSmtpEmail(
|
||||
to=to,
|
||||
html_content=html_content,
|
||||
sender=sender,
|
||||
subject=subject
|
||||
)
|
||||
|
||||
self.api_instance.send_transac_email(send_smtp_email)
|
||||
return True
|
||||
|
||||
except ApiException as e:
|
||||
print(f"Exception when sending budget warning (80%) email: {e}")
|
||||
return False
|
||||
|
||||
def send_budget_mail_100(self, to_email: EmailStr, recipient_name: Optional[str] = None) -> bool:
|
||||
"""Send a critical warning email when budget reaches 100% of limit."""
|
||||
try:
|
||||
subject = "Achtung: Abrechnungslimit erreicht!"
|
||||
sender = {"name": "Beyond The Loop", "email": os.getenv('SENDER_EMAIL', 'noreply@beyondtheloop.ai')}
|
||||
to = [{"email": to_email, "name": recipient_name or to_email}]
|
||||
|
||||
# Load and render the template
|
||||
template = self.jinja_env.get_template('budget-mail-100.html')
|
||||
html_content = template.render()
|
||||
|
||||
send_smtp_email = sib_api_v3_sdk.SendSmtpEmail(
|
||||
to=to,
|
||||
html_content=html_content,
|
||||
sender=sender,
|
||||
subject=subject
|
||||
)
|
||||
|
||||
self.api_instance.send_transac_email(send_smtp_email)
|
||||
return True
|
||||
|
||||
except ApiException as e:
|
||||
print(f"Exception when sending budget warning (100%) email: {e}")
|
||||
return False
|
78
backend/beyond_the_loop/templates/email/base.html
Normal file
78
backend/beyond_the_loop/templates/email/base.html
Normal file
@ -0,0 +1,78 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Bchat{% endblock %}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #f4f4f4;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin: 20px auto;
|
||||
background-color: #ffffff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #dddddd;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
color: #333333;
|
||||
}
|
||||
.content {
|
||||
padding: 20px 0;
|
||||
}
|
||||
.content p {
|
||||
color: #666666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.button {
|
||||
display: block;
|
||||
width: 200px;
|
||||
margin: 20px auto;
|
||||
padding: 10px 0;
|
||||
text-align: center;
|
||||
background-color: #007FFF;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #dddddd;
|
||||
color: #999999;
|
||||
font-size: 12px;
|
||||
}
|
||||
.footer a {
|
||||
color: #007BFF;
|
||||
text-decoration: none;
|
||||
}
|
||||
{% block additional_styles %}{% endblock %}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>{% block header %}{% endblock %}</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Diese E-Mail wurde automatisch generiert. Bitte antworte nicht direkt darauf. Bei Fragen wenden dich an unseren <a href="mailto:support@beyondtheloop.ai">Support</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
16
backend/beyond_the_loop/templates/email/budget-mail-100.html
Normal file
16
backend/beyond_the_loop/templates/email/budget-mail-100.html
Normal file
@ -0,0 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Achtung: Abrechnungslimit erreicht!{% endblock %}
|
||||
|
||||
{% block header %}Achtung: Abrechnungslimit erreicht!{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p>Hi,</p>
|
||||
<p>das festgelegte Abrechnungslimit für deinen Workspace wurde erreicht. Um Einschränkungen zu vermeiden, ist jetzt dringend deine Aktion erforderlich.</p>
|
||||
<p>Ohne sofortige Erhöhung des Limits kann dein Team nicht mehr auf BChat zugreifen. Erhöhe das Limit einfach in deinen Unternehmenseinstellungen!</p>
|
||||
<a href="https://bchat.example.com/billing" class="button">Jetzt Limit erhöhen</a>
|
||||
<p>Falls der Button nicht funktioniert, kopiere bitte diese URL und füge sie in deinen Browser ein:</p>
|
||||
<p><a href="https://bchat.example.com/billing">https://bchat.example.com/billing</a></p>
|
||||
<p>Zögere nicht, uns bei Fragen zu kontaktieren.</p>
|
||||
<p>Dein Beyond the Loop Team</p>
|
||||
{% endblock %}
|
15
backend/beyond_the_loop/templates/email/budget-mail-80.html
Normal file
15
backend/beyond_the_loop/templates/email/budget-mail-80.html
Normal file
@ -0,0 +1,15 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Abrechnungslimit fast erreicht{% endblock %}
|
||||
|
||||
{% block header %}Dein Abrechnungslimit ist fast erreicht!{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p>Hallo,</p>
|
||||
<p>dein Workspace hat gerade 80% des festgelegten Abrechnungslimits erreicht. Keine Sorge, alles läuft noch - aber es ist Zeit, einen Blick in deine Unternehmens-Dashboard zu werfen.</p>
|
||||
<p>Schraube das Limit rechtzeitig hoch, um sicherzustellen, dass jeder die Platform ohne Einschränkungen nutzen kann.</p>
|
||||
<a class="button" href="https://bchat.example.com/billing">Abrechnungsdetails überprüfen</a>
|
||||
<p>Falls du Fragen hast oder Hilfe brauchst - wir sind für dich da!</p>
|
||||
<p>Danke, dass du bChat nutzt!</p>
|
||||
<p>Dein Beyond the Loop Team</p>
|
||||
{% endblock %}
|
29
backend/beyond_the_loop/templates/email/welcome.html
Normal file
29
backend/beyond_the_loop/templates/email/welcome.html
Normal file
@ -0,0 +1,29 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Account Aktivierung{% endblock %}
|
||||
|
||||
{% block additional_styles %}
|
||||
.password {
|
||||
font-size: 24px;
|
||||
color: #333333;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}Willkommen bei Bchat!{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p>Hallo {{ name }},</p>
|
||||
<p>Du wurdest zu bChat eingeladen! Um loszulegen, logge dich mit deiner Email-Adresse und folgendem Passwort ein:</p>
|
||||
<div class="password">{{ password }}</div>
|
||||
<p>Viel Spaß beim Entdecken von bChat!</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block footer %}
|
||||
<p>Diese E-Mail wurde automatisch generiert. Bitte antworte nicht direkt darauf. Bei Fragen wenden dich an unseren <a href="mailto:support@beyondtheloop.ai">Support</a>.</p>
|
||||
{% endblock %}
|
@ -90,8 +90,11 @@ class SignupForm(BaseModel):
|
||||
profile_image_url: Optional[str] = "/user.png"
|
||||
|
||||
|
||||
class AddUserForm(SignupForm):
|
||||
role: Optional[str] = "pending",
|
||||
class AddUserForm(BaseModel):
|
||||
name: str
|
||||
email: str
|
||||
profile_image_url: Optional[str] = "/user.png"
|
||||
role: Optional[str] = "pending"
|
||||
|
||||
|
||||
class AuthsTable:
|
||||
|
@ -1 +1 @@
|
||||
Name,Email,Password,Role
|
||||
Name,Email,Role
|
||||
|
|
@ -106,3 +106,6 @@ google-cloud-storage==2.19.0
|
||||
|
||||
## LDAP
|
||||
ldap3==2.9.1
|
||||
|
||||
jinja2==3.1.3
|
||||
sib-api-v3-sdk==7.6.0
|
||||
|
@ -353,7 +353,6 @@ export const addUser = async (
|
||||
token: string,
|
||||
name: string,
|
||||
email: string,
|
||||
password: string,
|
||||
role: string = 'pending'
|
||||
) => {
|
||||
let error = null;
|
||||
@ -367,7 +366,6 @@ export const addUser = async (
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
email: email,
|
||||
password: password,
|
||||
role: role
|
||||
})
|
||||
})
|
||||
|
@ -20,7 +20,6 @@
|
||||
let _user = {
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'user'
|
||||
};
|
||||
|
||||
@ -28,7 +27,6 @@
|
||||
_user = {
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'user'
|
||||
};
|
||||
}
|
||||
@ -42,15 +40,11 @@
|
||||
if (tab === '') {
|
||||
loading = true;
|
||||
|
||||
const res = await addUser(
|
||||
localStorage.token,
|
||||
_user.name,
|
||||
_user.email,
|
||||
_user.password,
|
||||
_user.role
|
||||
).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
});
|
||||
const res = await addUser(localStorage.token, _user.name, _user.email, _user.role).catch(
|
||||
(error) => {
|
||||
toast.error(`${error}`);
|
||||
}
|
||||
);
|
||||
|
||||
if (res) {
|
||||
stopLoading();
|
||||
@ -75,15 +69,14 @@
|
||||
|
||||
if (idx > 0) {
|
||||
if (
|
||||
columns.length === 4 &&
|
||||
['admin', 'user', 'pending'].includes(columns[3].toLowerCase())
|
||||
columns.length === 3 &&
|
||||
['admin', 'user', 'pending'].includes(columns[2].toLowerCase())
|
||||
) {
|
||||
const res = await addUser(
|
||||
localStorage.token,
|
||||
columns[0],
|
||||
columns[1],
|
||||
columns[2],
|
||||
columns[3].toLowerCase()
|
||||
columns[0], // name
|
||||
columns[1], // email
|
||||
columns[2].toLowerCase() // role
|
||||
).catch((error) => {
|
||||
toast.error(`Row ${idx + 1}: ${error}`);
|
||||
return null;
|
||||
@ -201,7 +194,7 @@
|
||||
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
|
||||
type="text"
|
||||
bind:value={_user.name}
|
||||
placeholder={$i18n.t('Enter Your Full Name')}
|
||||
placeholder={$i18n.t('Enter Full Name')}
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
@ -218,25 +211,11 @@
|
||||
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
|
||||
type="email"
|
||||
bind:value={_user.email}
|
||||
placeholder={$i18n.t('Enter Your Email')}
|
||||
placeholder={$i18n.t('Enter Email')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col w-full mt-1">
|
||||
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Password')}</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
|
||||
type="password"
|
||||
bind:value={_user.password}
|
||||
placeholder={$i18n.t('Enter Your Password')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if tab === 'import'}
|
||||
<div>
|
||||
<div class="mb-3 w-full">
|
||||
@ -265,7 +244,7 @@
|
||||
|
||||
<div class=" text-xs text-gray-500">
|
||||
ⓘ {$i18n.t(
|
||||
'Ensure your CSV file includes 4 columns in this order: Name, Email, Password, Role.'
|
||||
'Ensure your CSV file includes 4 columns in this order: Name, Email, Role.'
|
||||
)}
|
||||
<a
|
||||
class="underline dark:text-gray-200"
|
||||
|
Loading…
Reference in New Issue
Block a user