feat: Add client portal with password setup email functionality

Implement a complete client portal feature that allows clients to access
their projects, invoices, and time entries through a dedicated portal with
separate authentication. Includes password setup via email with secure
token-based authentication.

Client Portal Features:
- Client-based authentication (separate from user accounts)
- Portal access can be enabled/disabled per client
- Clients can view their projects, invoices, and time entries
- Clean, minimal UI without main app navigation elements
- Login page styled to match main app design

Password Setup Email:
- Admin can send password setup emails to clients
- Secure token-based password setup (24-hour expiration)
- Email template with professional styling
- Password setup page matching app login design
- Token validation and automatic cleanup after use

Email Configuration:
- Email settings from admin menu are now used for sending
- Database email settings persist between restarts and updates
- Automatic reload of email configuration when sending emails
- Database settings take precedence over environment variables
- Improved error messages for email configuration issues

Database Changes:
- Add portal_enabled, portal_username, portal_password_hash to clients
- Add password_setup_token and password_setup_token_expires to clients
- Migration 047: Add client portal fields to users (legacy)
- Migration 048: Add client portal credentials to clients
- Migration 049: Add password setup token fields

New Files:
- app/routes/client_portal.py - Client portal routes and authentication
- app/templates/client_portal/ - Portal templates (base, login, dashboard, etc.)
- app/templates/email/client_portal_password_setup.html - Email template
- migrations/versions/047-049 - Database migrations
- tests/test_client_portal.py - Portal tests
- docs/CLIENT_PORTAL.md - Portal documentation

Modified Files:
- app/models/client.py - Add portal fields and password token methods
- app/routes/clients.py - Add send password email route
- app/routes/client_portal.py - Portal routes with redirect handling
- app/utils/email.py - Use database settings, add password setup email
- app/templates/clients/edit.html - Add send email button
- app/templates/components/ui.html - Support client portal breadcrumbs

Security:
- Secure token generation using secrets.token_urlsafe()
- Password hashing with werkzeug.security
- Token expiration (24 hours default)
- Token cleared after successful password setup
- CSRF protection on all forms
This commit is contained in:
Dries Peeters
2025-11-14 15:15:38 +01:00
parent a69cc8d3c1
commit 39cf649f8e
27 changed files with 2426 additions and 19 deletions

View File

@@ -849,6 +849,7 @@ def create_app(config=None):
from app.routes.budget_alerts import budget_alerts_bp
from app.routes.import_export import import_export_bp
from app.routes.webhooks import webhooks_bp
from app.routes.client_portal import client_portal_bp
try:
from app.routes.audit_logs import audit_logs_bp
app.register_blueprint(audit_logs_bp)
@@ -876,6 +877,7 @@ def create_app(config=None):
app.register_blueprint(payments_bp)
app.register_blueprint(clients_bp)
app.register_blueprint(client_notes_bp)
app.register_blueprint(client_portal_bp)
app.register_blueprint(comments_bp)
app.register_blueprint(kanban_bp)
app.register_blueprint(setup_bp)

View File

@@ -1,7 +1,9 @@
from datetime import datetime
from datetime import datetime, timedelta
from decimal import Decimal
from werkzeug.security import generate_password_hash, check_password_hash
from app import db
from .client_prepaid_consumption import ClientPrepaidConsumption
import secrets
class Client(db.Model):
"""Client model for managing client information and rates"""
@@ -22,6 +24,13 @@ class Client(db.Model):
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Client portal settings
portal_enabled = db.Column(db.Boolean, default=False, nullable=False) # Enable/disable client portal access
portal_username = db.Column(db.String(80), unique=True, nullable=True, index=True) # Portal login username
portal_password_hash = db.Column(db.String(255), nullable=True) # Hashed password for portal access
password_setup_token = db.Column(db.String(100), nullable=True, index=True) # Token for password setup/reset
password_setup_token_expires = db.Column(db.DateTime, nullable=True) # Token expiration time
# Relationships
projects = db.relationship('Project', backref='client_obj', lazy='dynamic', cascade='all, delete-orphan')
@@ -202,3 +211,111 @@ class Client(db.Model):
def get_all_clients(cls):
"""Get all clients ordered by name"""
return cls.query.order_by(cls.name).all()
# Client portal helpers
def set_portal_password(self, password):
"""Set the portal password for this client"""
if password:
self.portal_password_hash = generate_password_hash(password)
else:
self.portal_password_hash = None
def check_portal_password(self, password):
"""Check if the provided password matches the portal password"""
if not self.portal_password_hash or not password:
return False
return check_password_hash(self.portal_password_hash, password)
@property
def has_portal_access(self):
"""Check if client has portal access enabled and credentials set"""
return self.portal_enabled and self.portal_username and self.portal_password_hash
def get_portal_data(self):
"""Get data for client portal view (projects, invoices, time entries)"""
if not self.has_portal_access:
return None
from .project import Project
from .invoice import Invoice
from .time_entry import TimeEntry
# Get active projects for this client
projects = Project.query.filter_by(
client_id=self.id,
status='active'
).order_by(Project.name).all()
# Get invoices for this client
invoices = Invoice.query.filter_by(
client_id=self.id
).order_by(Invoice.issue_date.desc()).limit(50).all()
# Get time entries for projects belonging to this client
project_ids = [p.id for p in projects]
time_entries = TimeEntry.query.filter(
TimeEntry.project_id.in_(project_ids),
TimeEntry.end_time.isnot(None)
).order_by(TimeEntry.start_time.desc()).limit(100).all()
return {
'client': self,
'projects': projects,
'invoices': invoices,
'time_entries': time_entries
}
def generate_password_setup_token(self, expires_hours=24):
"""Generate a secure token for password setup/reset"""
token = secrets.token_urlsafe(32)
self.password_setup_token = token
self.password_setup_token_expires = datetime.utcnow() + timedelta(hours=expires_hours)
return token
def verify_password_setup_token(self, token):
"""Verify if a password setup token is valid"""
if not self.password_setup_token or not token:
return False
if self.password_setup_token != token:
return False
if self.password_setup_token_expires and self.password_setup_token_expires < datetime.utcnow():
return False
return True
def clear_password_setup_token(self):
"""Clear the password setup token after use"""
self.password_setup_token = None
self.password_setup_token_expires = None
@classmethod
def authenticate_portal(cls, username, password):
"""Authenticate a client portal login"""
client = cls.query.filter_by(portal_username=username, portal_enabled=True).first()
if not client:
return None
if not client.check_portal_password(password):
return None
if not client.is_active:
return None
return client
@classmethod
def find_by_password_token(cls, token):
"""Find a client by password setup token"""
if not token:
return None
client = cls.query.filter_by(password_setup_token=token).first()
if not client:
return None
if client.password_setup_token_expires and client.password_setup_token_expires < datetime.utcnow():
return None
return client

View File

@@ -45,11 +45,16 @@ class User(UserMixin, db.Model):
# Overtime settings
standard_hours_per_day = db.Column(db.Float, default=8.0, nullable=False) # Standard working hours per day for overtime calculation
# Client portal settings
client_portal_enabled = db.Column(db.Boolean, default=False, nullable=False) # Enable/disable client portal access
client_id = db.Column(db.Integer, db.ForeignKey('clients.id', ondelete='SET NULL'), nullable=True, index=True) # Link user to a client for portal access
# Relationships
time_entries = db.relationship('TimeEntry', backref='user', lazy='dynamic', cascade='all, delete-orphan')
project_costs = db.relationship('ProjectCost', backref='user', lazy='dynamic', cascade='all, delete-orphan')
favorite_projects = db.relationship('Project', secondary='user_favorite_projects', lazy='dynamic', backref=db.backref('favorited_by', lazy='dynamic'))
roles = db.relationship('Role', secondary='user_roles', lazy='joined', backref=db.backref('users', lazy='dynamic'))
client = db.relationship('Client', backref='portal_users', lazy='joined')
def __init__(self, username, role='user', email=None, full_name=None):
self.username = username.lower().strip()
@@ -237,3 +242,47 @@ class User(UserMixin, db.Model):
def get_role_names(self):
"""Get list of role names for this user"""
return [r.name for r in self.roles]
# Client portal helpers
@property
def is_client_portal_user(self):
"""Check if user has client portal access enabled"""
return self.client_portal_enabled and self.client_id is not None
def get_client_portal_data(self):
"""Get data for client portal view (projects, invoices, time entries for assigned client)"""
if not self.is_client_portal_user:
return None
from .project import Project
from .invoice import Invoice
from .time_entry import TimeEntry
client = self.client
if not client:
return None
# Get active projects for this client
projects = Project.query.filter_by(
client_id=client.id,
status='active'
).order_by(Project.name).all()
# Get invoices for this client
invoices = Invoice.query.filter_by(
client_id=client.id
).order_by(Invoice.issue_date.desc()).limit(50).all()
# Get time entries for projects belonging to this client
project_ids = [p.id for p in projects]
time_entries = TimeEntry.query.filter(
TimeEntry.project_id.in_(project_ids),
TimeEntry.end_time.isnot(None)
).order_by(TimeEntry.start_time.desc()).limit(100).all()
return {
'client': client,
'projects': projects,
'invoices': invoices,
'time_entries': time_entries
}

View File

@@ -184,35 +184,47 @@ def create_user():
@admin_or_permission_required('edit_users')
def edit_user(user_id):
"""Edit an existing user"""
from app.models import Client
user = User.query.get_or_404(user_id)
clients = Client.query.filter_by(status='active').order_by(Client.name).all()
if request.method == 'POST':
username = request.form.get('username', '').strip().lower()
role = request.form.get('role', 'user')
is_active = request.form.get('is_active') == 'on'
client_portal_enabled = request.form.get('client_portal_enabled') == 'on'
client_id = request.form.get('client_id', '').strip()
if not username:
flash('Username is required', 'error')
return render_template('admin/user_form.html', user=user)
return render_template('admin/user_form.html', user=user, clients=clients)
# Check if username is already taken by another user
existing_user = User.query.filter_by(username=username).first()
if existing_user and existing_user.id != user.id:
flash('Username already exists', 'error')
return render_template('admin/user_form.html', user=user)
return render_template('admin/user_form.html', user=user, clients=clients)
# Validate client portal settings
if client_portal_enabled and not client_id:
flash('Please select a client when enabling client portal access.', 'error')
return render_template('admin/user_form.html', user=user, clients=clients)
# Update user
user.username = username
user.role = role
user.is_active = is_active
user.client_portal_enabled = client_portal_enabled
user.client_id = int(client_id) if client_id else None
if not safe_commit('admin_edit_user', {'user_id': user.id}):
flash('Could not update user due to a database error. Please check server logs.', 'error')
return render_template('admin/user_form.html', user=user)
return render_template('admin/user_form.html', user=user, clients=clients)
flash(f'User "{username}" updated successfully', 'success')
return redirect(url_for('admin.list_users'))
return render_template('admin/user_form.html', user=user)
return render_template('admin/user_form.html', user=user, clients=clients)
@admin_bp.route('/admin/users/<int:user_id>/delete', methods=['POST'])
@login_required

364
app/routes/client_portal.py Normal file
View File

@@ -0,0 +1,364 @@
"""Client Portal Routes
Provides a simplified interface for clients to view their projects,
invoices, and time entries. Uses separate authentication from regular users.
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, abort, session
from flask_babel import gettext as _
from app import db
from app.models import Client, Project, Invoice, TimeEntry
from app.utils.db import safe_commit
from datetime import datetime, timedelta
from sqlalchemy import func
from functools import wraps
client_portal_bp = Blueprint('client_portal', __name__)
def get_current_client():
"""Get the currently logged-in client from session"""
client_id = session.get('client_portal_id')
if not client_id:
return None
return Client.query.get(client_id)
# Make get_current_client available to templates
@client_portal_bp.app_context_processor
def inject_get_current_client():
"""Make get_current_client available in templates"""
return dict(get_current_client=get_current_client)
def check_client_portal_access():
"""Helper function to check if client has portal access - redirects to login if not authenticated"""
client = get_current_client()
if not client:
flash(_('Please log in to access the client portal.'), 'error')
return redirect(url_for('client_portal.login', next=request.url))
if not client.has_portal_access:
flash(_('Client portal access is not enabled for your account.'), 'error')
session.pop('client_portal_id', None) # Clear invalid session
return redirect(url_for('client_portal.login'))
if not client.is_active:
flash(_('Your client account is inactive.'), 'error')
session.pop('client_portal_id', None) # Clear invalid session
return redirect(url_for('client_portal.login'))
return client
@client_portal_bp.route('/client-portal/login', methods=['GET', 'POST'])
def login():
"""Client portal login page"""
if request.method == 'GET':
# If already logged in, redirect to dashboard
if get_current_client():
return redirect(url_for('client_portal.dashboard'))
return render_template('client_portal/login.html')
# POST - handle login
username = request.form.get('username', '').strip()
password = request.form.get('password', '')
if not username or not password:
flash(_('Username and password are required.'), 'error')
return render_template('client_portal/login.html')
# Authenticate client
client = Client.authenticate_portal(username, password)
if not client:
flash(_('Invalid username or password.'), 'error')
return render_template('client_portal/login.html')
# Log in the client
session['client_portal_id'] = client.id
session.permanent = True
flash(_('Welcome, %(client_name)s!', client_name=client.name), 'success')
# Redirect to intended page or dashboard
next_page = request.form.get('next') or request.args.get('next')
if not next_page or not next_page.startswith('/client-portal'):
next_page = url_for('client_portal.dashboard')
return redirect(next_page)
@client_portal_bp.route('/client-portal/logout')
def logout():
"""Client portal logout"""
session.pop('client_portal_id', None)
flash(_('You have been logged out.'), 'info')
return redirect(url_for('client_portal.login'))
@client_portal_bp.route('/client-portal/set-password', methods=['GET', 'POST'])
def set_password():
"""Set or reset password using token from email"""
token = request.args.get('token')
if not token:
flash(_('Invalid or missing password setup token.'), 'error')
return redirect(url_for('client_portal.login'))
# Find client by token
client = Client.find_by_password_token(token)
if not client:
flash(_('Invalid or expired password setup token. Please request a new one.'), 'error')
return redirect(url_for('client_portal.login'))
if request.method == 'POST':
password = request.form.get('password', '').strip()
password_confirm = request.form.get('password_confirm', '').strip()
# Validate password
if not password:
flash(_('Password is required.'), 'error')
return render_template('client_portal/set_password.html', client=client, token=token)
if len(password) < 8:
flash(_('Password must be at least 8 characters long.'), 'error')
return render_template('client_portal/set_password.html', client=client, token=token)
if password != password_confirm:
flash(_('Passwords do not match.'), 'error')
return render_template('client_portal/set_password.html', client=client, token=token)
# Set password
client.set_portal_password(password)
client.clear_password_setup_token()
if not safe_commit('client_set_password', {'client_id': client.id}):
flash(_('Could not set password due to a database error.'), 'error')
return render_template('client_portal/set_password.html', client=client, token=token)
flash(_('Password set successfully! You can now log in to the portal.'), 'success')
return redirect(url_for('client_portal.login'))
return render_template('client_portal/set_password.html', client=client, token=token)
@client_portal_bp.route('/client-portal')
@client_portal_bp.route('/client-portal/dashboard')
def dashboard():
"""Client portal dashboard showing overview of projects, invoices, and time entries"""
result = check_client_portal_access()
if not isinstance(result, Client): # It's a redirect response
return result
client = result
portal_data = client.get_portal_data()
if not portal_data:
flash(_('Unable to load client portal data.'), 'error')
return redirect(url_for('client_portal.login'))
# Calculate statistics
total_projects = len(portal_data['projects'])
total_invoices = len(portal_data['invoices'])
total_time_entries = len(portal_data['time_entries'])
# Calculate total hours
total_hours = sum(entry.duration_hours for entry in portal_data['time_entries'])
# Calculate invoice totals
total_invoice_amount = sum(inv.total_amount for inv in portal_data['invoices'])
paid_invoice_amount = sum(
inv.total_amount for inv in portal_data['invoices']
if inv.payment_status == 'fully_paid'
)
unpaid_invoice_amount = sum(
inv.outstanding_amount for inv in portal_data['invoices']
if inv.payment_status != 'fully_paid'
)
# Get recent activity (last 30 days)
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
recent_time_entries = [
entry for entry in portal_data['time_entries']
if entry.start_time >= thirty_days_ago
]
# Group time entries by project
project_hours = {}
for entry in portal_data['time_entries']:
if not entry.project:
continue
project_id = entry.project.id
if project_id not in project_hours:
project_hours[project_id] = {
'project': entry.project,
'hours': 0.0
}
project_hours[project_id]['hours'] += entry.duration_hours
return render_template(
'client_portal/dashboard.html',
client=client,
projects=portal_data['projects'],
invoices=portal_data['invoices'],
time_entries=portal_data['time_entries'],
total_projects=total_projects,
total_invoices=total_invoices,
total_time_entries=total_time_entries,
total_hours=round(total_hours, 2),
total_invoice_amount=total_invoice_amount,
paid_invoice_amount=paid_invoice_amount,
unpaid_invoice_amount=unpaid_invoice_amount,
recent_time_entries=recent_time_entries,
project_hours=list(project_hours.values())
)
@client_portal_bp.route('/client-portal/projects')
def projects():
"""List all projects for the client"""
result = check_client_portal_access()
if not isinstance(result, Client):
return result
client = result
portal_data = client.get_portal_data()
if not portal_data:
flash(_('Unable to load client portal data.'), 'error')
return redirect(url_for('client_portal.dashboard'))
# Calculate hours per project
project_stats = []
for project in portal_data['projects']:
project_entries = [
entry for entry in portal_data['time_entries']
if entry.project_id == project.id
]
total_hours = sum(entry.duration_hours for entry in project_entries)
project_stats.append({
'project': project,
'total_hours': round(total_hours, 2),
'entry_count': len(project_entries)
})
return render_template(
'client_portal/projects.html',
client=client,
project_stats=project_stats
)
@client_portal_bp.route('/client-portal/invoices')
def invoices():
"""List all invoices for the client"""
result = check_client_portal_access()
if not isinstance(result, Client):
return result
client = result
portal_data = client.get_portal_data()
if not portal_data:
flash(_('Unable to load client portal data.'), 'error')
return redirect(url_for('client_portal.dashboard'))
# Filter invoices by status if requested
status_filter = request.args.get('status', 'all')
filtered_invoices = portal_data['invoices']
if status_filter == 'paid':
filtered_invoices = [inv for inv in filtered_invoices if inv.payment_status == 'fully_paid']
elif status_filter == 'unpaid':
filtered_invoices = [
inv for inv in filtered_invoices
if inv.payment_status in ['unpaid', 'partially_paid']
]
elif status_filter == 'overdue':
filtered_invoices = [inv for inv in filtered_invoices if inv.is_overdue]
return render_template(
'client_portal/invoices.html',
client=client,
invoices=filtered_invoices,
status_filter=status_filter
)
@client_portal_bp.route('/client-portal/invoices/<int:invoice_id>')
def view_invoice(invoice_id):
"""View a specific invoice"""
result = check_client_portal_access()
if not isinstance(result, Client):
return result
client = result
# Verify invoice belongs to this client
invoice = Invoice.query.get_or_404(invoice_id)
if invoice.client_id != client.id:
flash(_('Invoice not found.'), 'error')
abort(404)
return render_template(
'client_portal/invoice_detail.html',
client=client,
invoice=invoice
)
@client_portal_bp.route('/client-portal/time-entries')
def time_entries():
"""List time entries for the client's projects"""
result = check_client_portal_access()
if not isinstance(result, Client):
return result
client = result
portal_data = client.get_portal_data()
if not portal_data:
flash(_('Unable to load client portal data.'), 'error')
return redirect(url_for('client_portal.dashboard'))
# Filter by project if requested
project_id = request.args.get('project_id', type=int)
filtered_entries = portal_data['time_entries']
if project_id:
filtered_entries = [
entry for entry in filtered_entries
if entry.project_id == project_id
]
# Filter by date range if requested
date_from = request.args.get('date_from')
date_to = request.args.get('date_to')
if date_from:
try:
date_from_dt = datetime.strptime(date_from, '%Y-%m-%d')
filtered_entries = [
entry for entry in filtered_entries
if entry.start_time.date() >= date_from_dt.date()
]
except ValueError:
pass
if date_to:
try:
date_to_dt = datetime.strptime(date_to, '%Y-%m-%d')
filtered_entries = [
entry for entry in filtered_entries
if entry.start_time.date() <= date_to_dt.date()
]
except ValueError:
pass
return render_template(
'client_portal/time_entries.html',
client=client,
projects=portal_data['projects'],
time_entries=filtered_entries,
selected_project_id=project_id,
date_from=date_from,
date_to=date_to
)

View File

@@ -9,6 +9,7 @@ from decimal import Decimal, InvalidOperation
from app.utils.db import safe_commit
from app.utils.permissions import admin_or_permission_required
from app.utils.timezone import convert_app_datetime_to_user
from app.utils.email import send_client_portal_password_setup_email
import csv
import io
@@ -275,6 +276,23 @@ def edit_client(client_id):
flash(_('Prepaid reset day must be between 1 and 28.'), 'error')
return render_template('clients/edit.html', client=client)
# Handle portal settings
portal_enabled = request.form.get('portal_enabled') == 'on'
portal_username = request.form.get('portal_username', '').strip()
portal_password = request.form.get('portal_password', '').strip()
# Validate portal settings
if portal_enabled:
if not portal_username:
flash(_('Portal username is required when enabling portal access.'), 'error')
return render_template('clients/edit.html', client=client)
# Check if portal username is already taken by another client
existing_client = Client.query.filter_by(portal_username=portal_username).first()
if existing_client and existing_client.id != client.id:
flash(_('This portal username is already in use by another client.'), 'error')
return render_template('clients/edit.html', client=client)
# Update client
client.name = name
client.description = description
@@ -285,6 +303,18 @@ def edit_client(client_id):
client.default_hourly_rate = default_hourly_rate
client.prepaid_hours_monthly = prepaid_hours_monthly
client.prepaid_reset_day = prepaid_reset_day
client.portal_enabled = portal_enabled
# Update portal credentials
if portal_enabled:
client.portal_username = portal_username
if portal_password: # Only update password if provided
client.set_portal_password(portal_password)
else:
# Disable portal - clear credentials
client.portal_username = None
client.portal_password_hash = None
client.updated_at = datetime.utcnow()
if not safe_commit('edit_client', {'client_id': client.id}):
@@ -300,6 +330,68 @@ def edit_client(client_id):
return render_template('clients/edit.html', client=client)
@clients_bp.route('/clients/<int:client_id>/send-portal-password-email', methods=['POST'])
@login_required
def send_portal_password_email(client_id):
"""Send password setup email to client"""
client = Client.query.get_or_404(client_id)
# Check permissions
if not current_user.is_admin and not current_user.has_permission('edit_clients'):
flash(_('You do not have permission to send portal emails'), 'error')
return redirect(url_for('clients.view_client', client_id=client_id))
# Check if portal is enabled and username is set
if not client.portal_enabled:
flash(_('Client portal is not enabled for this client.'), 'error')
return redirect(url_for('clients.edit_client', client_id=client_id))
if not client.portal_username:
flash(_('Portal username is not set for this client.'), 'error')
return redirect(url_for('clients.edit_client', client_id=client_id))
if not client.email:
flash(_('Client email address is not set. Cannot send password setup email.'), 'error')
return redirect(url_for('clients.edit_client', client_id=client_id))
# Generate password setup token
token = client.generate_password_setup_token(expires_hours=24)
if not safe_commit('client_generate_password_token', {'client_id': client.id}):
flash(_('Could not generate password setup token due to a database error.'), 'error')
return redirect(url_for('clients.edit_client', client_id=client_id))
# Send email
try:
# Ensure we're using latest database email settings
from app.utils.email import reload_mail_config
from app.models import Settings
settings = Settings.get_settings()
if settings.mail_enabled:
reload_mail_config(current_app._get_current_object())
success = send_client_portal_password_setup_email(client, token)
if success:
flash(_('Password setup email sent successfully to %(email)s', email=client.email), 'success')
else:
# Check email configuration to provide better error message
db_config = settings.get_mail_config()
if db_config:
mail_server = db_config.get('MAIL_SERVER')
else:
mail_server = current_app.config.get('MAIL_SERVER')
if not mail_server or mail_server == 'localhost':
flash(_('Email server is not configured. Please configure email settings in Admin → Email Configuration or set MAIL_SERVER environment variable.'), 'error')
else:
flash(_('Failed to send password setup email. Please check email configuration and server logs for details.'), 'error')
except Exception as e:
current_app.logger.error(f"Error sending password setup email: {e}")
flash(_('An error occurred while sending the email: %(error)s', error=str(e)), 'error')
return redirect(url_for('clients.edit_client', client_id=client_id))
@clients_bp.route('/clients/<int:client_id>/archive', methods=['POST'])
@login_required
def archive_client(client_id):

View File

@@ -31,6 +31,29 @@
<label for="is_active" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">Active</label>
</div>
<div class="border border-border-light dark:border-border-dark rounded-lg p-4 bg-bg-secondary-light dark:bg-bg-secondary-dark">
<h3 class="font-semibold mb-2">Client Portal Access</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-3">
Enable client portal access for this user. When enabled, the user will only see data for their assigned client.
</p>
<div class="space-y-3">
<div class="flex items-center">
<input type="checkbox" name="client_portal_enabled" id="client_portal_enabled" {% if user.client_portal_enabled %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" onchange="toggleClientSelect()">
<label for="client_portal_enabled" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">Enable Client Portal</label>
</div>
<div id="client_select_container" style="display: {% if user.client_portal_enabled %}block{% else %}none{% endif %};">
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Assign Client</label>
<select name="client_id" id="client_id" class="form-input">
<option value="">-- Select Client --</option>
{% for client in clients %}
<option value="{{ client.id }}" {% if user.client_id == client.id %}selected{% endif %}>{{ client.name }}</option>
{% endfor %}
</select>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">Select the client this user should have access to.</p>
</div>
</div>
</div>
<div class="border border-border-light dark:border-border-dark rounded-lg p-4 bg-bg-secondary-light dark:bg-bg-secondary-dark">
<h3 class="font-semibold mb-2">Advanced Permissions</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-3">
@@ -55,4 +78,16 @@
</div>
</form>
</div>
<script>
function toggleClientSelect() {
const checkbox = document.getElementById('client_portal_enabled');
const container = document.getElementById('client_select_container');
if (checkbox.checked) {
container.style.display = 'block';
} else {
container.style.display = 'none';
}
}
</script>
{% endblock %}

View File

@@ -60,9 +60,16 @@
{% endif %}
</td>
<td class="p-4">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {{ 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' if user.is_active else 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' }}">
{{ 'Active' if user.is_active else 'Inactive' }}
</span>
<div class="flex flex-col gap-1">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {{ 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' if user.is_active else 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' }}">
{{ 'Active' if user.is_active else 'Inactive' }}
</span>
{% if user.client_portal_enabled %}
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200" title="Client Portal: {{ user.client.name if user.client else 'No client assigned' }}">
<i class="fas fa-building mr-1"></i>Portal
</span>
{% endif %}
</div>
</td>
<td class="p-4">
<div class="flex gap-2">

View File

@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="{{ current_language_code or 'en' }}" dir="{{ 'rtl' if is_rtl else 'ltr' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ _('Client Portal') }} - {{ app_name }}{% endblock %}</title>
<meta name="csrf-token" content="{{ csrf_token() }}">
<meta name="description" content="Client Portal - View your projects, invoices, and time entries">
<link rel="stylesheet" href="{{ url_for('static', filename='dist/output.css') }}">
<!-- Font Awesome -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='toast-notifications.css') }}">
<script>
// Theme init
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
</script>
</head>
<body class="bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark">
<!-- Simple header for client portal -->
<header class="bg-card-light dark:bg-card-dark border-b border-border-light dark:border-border-dark shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<div class="flex items-center">
<h1 class="text-xl font-bold text-primary">{{ _('Client Portal') }}</h1>
{% set current_client = get_current_client() %}
{% if current_client %}
<span class="ml-4 text-sm text-text-muted-light dark:text-text-muted-dark">{{ current_client.name }}</span>
{% endif %}
</div>
{% set current_client = get_current_client() %}
{% if current_client %}
<nav class="flex items-center space-x-4">
<a href="{{ url_for('client_portal.dashboard') }}" class="text-sm text-text-light dark:text-text-dark hover:text-primary transition-colors">
<i class="fas fa-home mr-1"></i>{{ _('Dashboard') }}
</a>
<a href="{{ url_for('client_portal.projects') }}" class="text-sm text-text-light dark:text-text-dark hover:text-primary transition-colors">
<i class="fas fa-folder-open mr-1"></i>{{ _('Projects') }}
</a>
<a href="{{ url_for('client_portal.invoices') }}" class="text-sm text-text-light dark:text-text-dark hover:text-primary transition-colors">
<i class="fas fa-file-invoice mr-1"></i>{{ _('Invoices') }}
</a>
<a href="{{ url_for('client_portal.time_entries') }}" class="text-sm text-text-light dark:text-text-dark hover:text-primary transition-colors">
<i class="fas fa-clock mr-1"></i>{{ _('Time Entries') }}
</a>
<a href="{{ url_for('client_portal.logout') }}" class="text-sm text-text-light dark:text-text-dark hover:text-red-600 transition-colors">
<i class="fas fa-sign-out-alt mr-1"></i>{{ _('Logout') }}
</a>
</nav>
{% endif %}
</div>
</div>
</header>
<!-- Main content -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{% from "components/ui.html" import breadcrumb_nav %}
{% block content %}{% endblock %}
</main>
<!-- Flash Messages (hidden; converted to toasts) -->
<div id="flash-messages-container" class="hidden">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert {% if category == 'success' %}alert-success{% elif category == 'error' %}alert-danger{% elif category == 'warning' %}alert-warning{% else %}alert-info{% endif %}" data-toast-message="{{ message }}" data-toast-type="{{ category }}"></div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/js/all.min.js"></script>
<script src="{{ url_for('static', filename='toast-notifications.js') }}"></script>
<script src="{{ url_for('static', filename='toast-manager.js') }}"></script>
</body>
</html>

View File

@@ -0,0 +1,155 @@
{% extends "client_portal/base.html" %}
{% from "components/ui.html" import page_header %}
{% block title %}{{ _('Client Portal') }} - {{ client.name }}{% endblock %}
{% block content %}
{% set breadcrumbs = [
{'text': _('Client Portal'), 'url': url_for('client_portal.dashboard')}
] %}
{{ page_header(
icon_class='fas fa-building',
title_text=_('Client Portal'),
subtitle_text=_('Welcome, %(client_name)s', client_name=client.name),
breadcrumbs=breadcrumbs
) }}
<!-- Statistics Cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Active Projects') }}</p>
<p class="text-2xl font-bold text-text-light dark:text-text-dark">{{ total_projects }}</p>
</div>
<i class="fas fa-folder-open text-primary text-3xl"></i>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Total Hours') }}</p>
<p class="text-2xl font-bold text-text-light dark:text-text-dark">{{ "%.2f"|format(total_hours) }}</p>
</div>
<i class="fas fa-clock text-green-600 text-3xl"></i>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Total Invoices') }}</p>
<p class="text-2xl font-bold text-text-light dark:text-text-dark">{{ total_invoices }}</p>
</div>
<i class="fas fa-file-invoice text-blue-600 text-3xl"></i>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Outstanding') }}</p>
<p class="text-2xl font-bold text-text-light dark:text-text-dark">{{ "%.2f"|format(unpaid_invoice_amount) }} {{ invoices[0].currency_code if invoices else 'EUR' }}</p>
</div>
<i class="fas fa-exclamation-triangle text-yellow-600 text-3xl"></i>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Projects Overview -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold">{{ _('Projects') }}</h2>
<a href="{{ url_for('client_portal.projects') }}" class="text-primary hover:underline text-sm">{{ _('View All') }}</a>
</div>
{% if projects %}
<div class="space-y-3">
{% for project in projects[:5] %}
<div class="flex items-center justify-between p-3 bg-background-light dark:bg-background-dark rounded-lg">
<div>
<p class="font-medium">{{ project.name }}</p>
{% if project.description %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ project.description[:50] }}{% if project.description|length > 50 %}...{% endif %}</p>
{% endif %}
</div>
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">{{ project.status|capitalize }}</span>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('No projects found.') }}</p>
{% endif %}
</div>
<!-- Recent Invoices -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold">{{ _('Recent Invoices') }}</h2>
<a href="{{ url_for('client_portal.invoices') }}" class="text-primary hover:underline text-sm">{{ _('View All') }}</a>
</div>
{% if invoices %}
<div class="space-y-3">
{% for invoice in invoices[:5] %}
<div class="flex items-center justify-between p-3 bg-background-light dark:bg-background-dark rounded-lg">
<div>
<a href="{{ url_for('client_portal.view_invoice', invoice_id=invoice.id) }}" class="font-medium text-primary hover:underline">{{ invoice.invoice_number }}</a>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ invoice.issue_date.strftime('%Y-%m-%d') }}</p>
</div>
<div class="text-right">
<p class="font-medium">{{ "%.2f"|format(invoice.total_amount) }} {{ invoice.currency_code }}</p>
<span class="px-2 py-1 text-xs rounded-full
{% if invoice.payment_status == 'fully_paid' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
{% elif invoice.is_overdue %}bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200
{% else %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200{% endif %}">
{{ invoice.payment_status|replace('_', ' ')|title }}
</span>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('No invoices found.') }}</p>
{% endif %}
</div>
</div>
<!-- Recent Time Entries -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold">{{ _('Recent Time Entries') }}</h2>
<a href="{{ url_for('client_portal.time_entries') }}" class="text-primary hover:underline text-sm">{{ _('View All') }}</a>
</div>
{% if recent_time_entries %}
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead class="border-b border-border-light dark:border-border-dark">
<tr>
<th class="p-3">{{ _('Date') }}</th>
<th class="p-3">{{ _('Project') }}</th>
<th class="p-3">{{ _('User') }}</th>
<th class="p-3">{{ _('Duration') }}</th>
<th class="p-3">{{ _('Description') }}</th>
</tr>
</thead>
<tbody>
{% for entry in recent_time_entries[:10] %}
<tr class="border-b border-border-light dark:border-border-dark">
<td class="p-3">{{ entry.start_time.strftime('%Y-%m-%d') }}</td>
<td class="p-3">{{ entry.project.name if entry.project else _('N/A') }}</td>
<td class="p-3">{{ entry.user.display_name if entry.user else _('N/A') }}</td>
<td class="p-3">{{ "%.2f"|format(entry.duration_hours) }}h</td>
<td class="p-3">{{ entry.description[:50] if entry.description else '-' }}{% if entry.description and entry.description|length > 50 %}...{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('No time entries found.') }}</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,120 @@
{% extends "client_portal/base.html" %}
{% from "components/ui.html" import page_header %}
{% block title %}{{ invoice.invoice_number }} - {{ _('Client Portal') }}{% endblock %}
{% block content %}
{% set breadcrumbs = [
{'text': _('Client Portal'), 'url': url_for('client_portal.dashboard')},
{'text': _('Invoices'), 'url': url_for('client_portal.invoices')},
{'text': invoice.invoice_number}
] %}
{{ page_header(
icon_class='fas fa-file-invoice',
title_text=invoice.invoice_number,
subtitle_text=_('Invoice Details'),
breadcrumbs=breadcrumbs
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div>
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark mb-2">{{ _('Client') }}</h3>
<p class="font-medium">{{ invoice.client_name }}</p>
{% if invoice.client_email %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ invoice.client_email }}</p>
{% endif %}
{% if invoice.client_address %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark whitespace-pre-line">{{ invoice.client_address }}</p>
{% endif %}
</div>
<div>
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark mb-2">{{ _('Invoice Details') }}</h3>
<p class="text-sm mb-1"><span class="font-medium">{{ _('Issue Date') }}:</span> {{ invoice.issue_date.strftime('%Y-%m-%d') }}</p>
<p class="text-sm mb-1"><span class="font-medium">{{ _('Due Date') }}:</span> {{ invoice.due_date.strftime('%Y-%m-%d') }}</p>
<p class="text-sm mb-1"><span class="font-medium">{{ _('Status') }}:</span>
<span class="px-2 py-1 text-xs rounded-full
{% if invoice.payment_status == 'fully_paid' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
{% elif invoice.is_overdue %}bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200
{% else %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200{% endif %}">
{{ invoice.payment_status|replace('_', ' ')|title }}
</span>
</p>
{% if invoice.is_overdue %}
<p class="text-sm text-red-600 mt-2">{{ _('This invoice is %(days)d days overdue.', days=invoice.days_overdue) }}</p>
{% endif %}
</div>
</div>
{% if invoice.items %}
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4">{{ _('Invoice Items') }}</h3>
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead class="border-b border-border-light dark:border-border-dark">
<tr>
<th class="p-3">{{ _('Description') }}</th>
<th class="p-3">{{ _('Quantity') }}</th>
<th class="p-3">{{ _('Unit Price') }}</th>
<th class="p-3">{{ _('Total') }}</th>
</tr>
</thead>
<tbody>
{% for item in invoice.items %}
<tr class="border-b border-border-light dark:border-border-dark">
<td class="p-3">{{ item.description }}</td>
<td class="p-3">{{ "%.2f"|format(item.quantity) }}</td>
<td class="p-3">{{ "%.2f"|format(item.unit_price) }} {{ invoice.currency_code }}</td>
<td class="p-3 font-medium">{{ "%.2f"|format(item.total_amount) }} {{ invoice.currency_code }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<div class="flex justify-end">
<div class="w-full md:w-1/3">
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Subtotal') }}:</span>
<span>{{ "%.2f"|format(invoice.subtotal) }} {{ invoice.currency_code }}</span>
</div>
{% if invoice.tax_rate > 0 %}
<div class="flex justify-between">
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Tax') }} ({{ "%.2f"|format(invoice.tax_rate) }}%):</span>
<span>{{ "%.2f"|format(invoice.tax_amount) }} {{ invoice.currency_code }}</span>
</div>
{% endif %}
<div class="flex justify-between text-lg font-bold pt-2 border-t border-border-light dark:border-border-dark">
<span>{{ _('Total') }}:</span>
<span>{{ "%.2f"|format(invoice.total_amount) }} {{ invoice.currency_code }}</span>
</div>
{% if invoice.payment_status != 'fully_paid' %}
<div class="flex justify-between text-sm text-red-600 pt-2">
<span>{{ _('Outstanding') }}:</span>
<span>{{ "%.2f"|format(invoice.outstanding_amount) }} {{ invoice.currency_code }}</span>
</div>
{% endif %}
</div>
</div>
</div>
{% if invoice.notes %}
<div class="mt-6 pt-6 border-t border-border-light dark:border-border-dark">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark mb-2">{{ _('Notes') }}</h3>
<p class="whitespace-pre-line">{{ invoice.notes }}</p>
</div>
{% endif %}
{% if invoice.terms %}
<div class="mt-6 pt-6 border-t border-border-light dark:border-border-dark">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark mb-2">{{ _('Terms') }}</h3>
<p class="whitespace-pre-line">{{ invoice.terms }}</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,95 @@
{% extends "client_portal/base.html" %}
{% from "components/ui.html" import page_header %}
{% block title %}{{ _('Invoices') }} - {{ _('Client Portal') }}{% endblock %}
{% block content %}
{% set breadcrumbs = [
{'text': _('Client Portal'), 'url': url_for('client_portal.dashboard')},
{'text': _('Invoices')}
] %}
{{ page_header(
icon_class='fas fa-file-invoice',
title_text=_('Invoices'),
subtitle_text=_('Invoices for %(client_name)s', client_name=client.name),
breadcrumbs=breadcrumbs
) }}
<!-- Filter Tabs -->
<div class="bg-card-light dark:bg-card-dark p-4 rounded-lg shadow mb-6">
<div class="flex gap-2">
<a href="{{ url_for('client_portal.invoices', status='all') }}"
class="px-4 py-2 rounded-lg {% if status_filter == 'all' %}bg-primary text-white{% else %}bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700{% endif %}">
{{ _('All') }}
</a>
<a href="{{ url_for('client_portal.invoices', status='paid') }}"
class="px-4 py-2 rounded-lg {% if status_filter == 'paid' %}bg-primary text-white{% else %}bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700{% endif %}">
{{ _('Paid') }}
</a>
<a href="{{ url_for('client_portal.invoices', status='unpaid') }}"
class="px-4 py-2 rounded-lg {% if status_filter == 'unpaid' %}bg-primary text-white{% else %}bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700{% endif %}">
{{ _('Unpaid') }}
</a>
<a href="{{ url_for('client_portal.invoices', status='overdue') }}"
class="px-4 py-2 rounded-lg {% if status_filter == 'overdue' %}bg-primary text-white{% else %}bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700{% endif %}">
{{ _('Overdue') }}
</a>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
{% if invoices %}
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead class="border-b border-border-light dark:border-border-dark">
<tr>
<th class="p-3">{{ _('Invoice Number') }}</th>
<th class="p-3">{{ _('Issue Date') }}</th>
<th class="p-3">{{ _('Due Date') }}</th>
<th class="p-3">{{ _('Amount') }}</th>
<th class="p-3">{{ _('Status') }}</th>
<th class="p-3">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for invoice in invoices %}
<tr class="border-b border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark">
<td class="p-3">
<a href="{{ url_for('client_portal.view_invoice', invoice_id=invoice.id) }}" class="text-primary hover:underline font-medium">
{{ invoice.invoice_number }}
</a>
</td>
<td class="p-3">{{ invoice.issue_date.strftime('%Y-%m-%d') }}</td>
<td class="p-3">
{{ invoice.due_date.strftime('%Y-%m-%d') }}
{% if invoice.is_overdue %}
<span class="text-red-600 text-xs ml-1">({{ invoice.days_overdue }} {{ _('days overdue') }})</span>
{% endif %}
</td>
<td class="p-3 font-medium">{{ "%.2f"|format(invoice.total_amount) }} {{ invoice.currency_code }}</td>
<td class="p-3">
<span class="px-2 py-1 text-xs rounded-full
{% if invoice.payment_status == 'fully_paid' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
{% elif invoice.is_overdue %}bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200
{% elif invoice.payment_status == 'partially_paid' %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
{{ invoice.payment_status|replace('_', ' ')|title }}
</span>
</td>
<td class="p-3">
<a href="{{ url_for('client_portal.view_invoice', invoice_id=invoice.id) }}" class="text-primary hover:underline text-sm">
{{ _('View') }}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-text-muted-light dark:text-text-muted-dark text-center py-8">{{ _('No invoices found.') }}</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ _('Client Portal Login') }} - {{ app_name }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='dist/output.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='toast-notifications.css') }}">
<script>
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark')
}
</script>
</head>
<body class="bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark">
<div class="min-h-screen flex items-center justify-center px-4">
<div class="w-full max-w-4xl grid grid-cols-1 md:grid-cols-2 gap-0 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-xl shadow-lg overflow-hidden">
<div class="hidden md:flex items-center justify-center p-10 bg-background-light dark:bg-background-dark">
<div class="text-center">
<img src="{{ url_for('static', filename='images/drytrix-logo.svg') }}" alt="logo" class="w-24 h-24 mx-auto">
<h1 class="text-3xl font-bold mt-4 text-primary">TimeTracker</h1>
<p class="mt-2 text-text-muted-light dark:text-text-muted-dark">{{ _('Client Portal') }}</p>
<p class="mt-1 text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('View your projects and invoices') }}</p>
</div>
</div>
<div class="p-8">
<h2 class="text-2xl font-bold tracking-tight">{{ _('Sign in to Client Portal') }}</h2>
<p class="mt-2 text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Enter your portal credentials to access your projects and invoices') }}</p>
<form class="mt-6" method="POST" action="{{ url_for('client_portal.login') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if request.args.get('next') %}
<input type="hidden" name="next" value="{{ request.args.get('next')|e }}">
{% endif %}
<label for="username" class="block mb-2 text-sm font-medium">{{ _('Username') }}</label>
<div class="relative">
<i class="fa-solid fa-user absolute left-3 top-1/2 -translate-y-1/2 text-text-muted-light dark:text-text-muted-dark"></i>
<input type="text" name="username" id="username" autocomplete="username" class="w-full pl-10 bg-background-light dark:bg-gray-700 border border-border-light dark:border-border-dark rounded-lg px-3 py-2 text-text-light dark:text-text-dark placeholder-text-muted-light dark:placeholder-text-muted-dark focus:outline-none focus:ring-2 focus:ring-primary" placeholder="{{ _('portal_username') }}" required>
</div>
<label for="password" class="block mb-2 text-sm font-medium mt-4">{{ _('Password') }}</label>
<div class="relative">
<i class="fa-solid fa-lock absolute left-3 top-1/2 -translate-y-1/2 text-text-muted-light dark:text-text-muted-dark"></i>
<input type="password" name="password" id="password" autocomplete="current-password" class="w-full pl-10 bg-background-light dark:bg-gray-700 border border-border-light dark:border-border-dark rounded-lg px-3 py-2 text-text-light dark:text-text-dark placeholder-text-muted-light dark:placeholder-text-muted-dark focus:outline-none focus:ring-2 focus:ring-primary" placeholder="{{ _('Enter your password') }}" required>
</div>
<button type="submit" class="btn btn-primary w-full mt-6">{{ _('Sign in') }}</button>
</form>
</div>
</div>
</div>
<!-- Flash Messages (hidden; converted to toasts) -->
<div id="flash-messages-container" class="hidden">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert {% if category == 'success' %}alert-success{% elif category == 'error' %}alert-danger{% elif category == 'warning' %}alert-warning{% else %}alert-info{% endif %}" data-toast-message="{{ message }}" data-toast-type="{{ category }}"></div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/js/all.min.js"></script>
<script src="{{ url_for('static', filename='toast-notifications.js') }}"></script>
<script src="{{ url_for('static', filename='toast-manager.js') }}"></script>
<script src="{{ url_for('static', filename='error-handling-enhanced.js') }}"></script>
</body>
</html>

View File

@@ -0,0 +1,53 @@
{% extends "client_portal/base.html" %}
{% from "components/ui.html" import page_header %}
{% block title %}{{ _('Projects') }} - {{ _('Client Portal') }}{% endblock %}
{% block content %}
{% set breadcrumbs = [
{'text': _('Client Portal'), 'url': url_for('client_portal.dashboard')},
{'text': _('Projects')}
] %}
{{ page_header(
icon_class='fas fa-folder-open',
title_text=_('Projects'),
subtitle_text=_('Projects for %(client_name)s', client_name=client.name),
breadcrumbs=breadcrumbs
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
{% if project_stats %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for stat in project_stats %}
<div class="border border-border-light dark:border-border-dark rounded-lg p-4 hover:shadow-md transition-shadow">
<div class="flex items-start justify-between mb-2">
<h3 class="text-lg font-semibold">{{ stat.project.name }}</h3>
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">{{ stat.project.status|capitalize }}</span>
</div>
{% if stat.project.description %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-3">{{ stat.project.description }}</p>
{% endif %}
<div class="flex items-center justify-between text-sm">
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Total Hours') }}:</span>
<span class="font-medium">{{ "%.2f"|format(stat.total_hours) }}h</span>
</div>
<div class="flex items-center justify-between text-sm mt-1">
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Time Entries') }}:</span>
<span class="font-medium">{{ stat.entry_count }}</span>
</div>
{% if stat.project.hourly_rate %}
<div class="flex items-center justify-between text-sm mt-1">
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Hourly Rate') }}:</span>
<span class="font-medium">{{ "%.2f"|format(stat.project.hourly_rate) }} {{ stat.project.client_obj.default_hourly_rate and 'EUR' or '' }}</span>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<p class="text-text-muted-light dark:text-text-muted-dark text-center py-8">{{ _('No projects found.') }}</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ _('Set Password') }} - {{ _('Client Portal') }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='dist/output.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='toast-notifications.css') }}">
<script>
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark')
}
</script>
</head>
<body class="bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark">
<div class="min-h-screen flex items-center justify-center px-4">
<div class="w-full max-w-4xl grid grid-cols-1 md:grid-cols-2 gap-0 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-xl shadow-lg overflow-hidden">
<div class="hidden md:flex items-center justify-center p-10 bg-background-light dark:bg-background-dark">
<div class="text-center">
<img src="{{ url_for('static', filename='images/drytrix-logo.svg') }}" alt="logo" class="w-24 h-24 mx-auto">
<h1 class="text-3xl font-bold mt-4 text-primary">TimeTracker</h1>
<p class="mt-2 text-text-muted-light dark:text-text-muted-dark">{{ _('Client Portal') }}</p>
<p class="mt-1 text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Set your password to get started') }}</p>
</div>
</div>
<div class="p-8">
<h2 class="text-2xl font-bold tracking-tight">{{ _('Set Your Password') }}</h2>
<p class="mt-2 text-sm text-text-muted-light dark:text-text-muted-dark">
{{ _('Set a password for your client portal account') }}
</p>
{% if client %}
<div class="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p class="text-sm text-blue-800 dark:text-blue-200">
<strong>{{ _('Client') }}:</strong> {{ client.name }}<br>
<strong>{{ _('Username') }}:</strong> {{ client.portal_username }}
</p>
</div>
{% endif %}
<form class="mt-6" method="POST" action="{{ url_for('client_portal.set_password', token=token) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="token" value="{{ token }}">
<label for="password" class="block mb-2 text-sm font-medium">{{ _('Password') }}</label>
<div class="relative">
<i class="fa-solid fa-lock absolute left-3 top-1/2 -translate-y-1/2 text-text-muted-light dark:text-text-muted-dark"></i>
<input type="password" name="password" id="password" autocomplete="new-password" class="w-full pl-10 bg-background-light dark:bg-gray-700 border border-border-light dark:border-border-dark rounded-lg px-3 py-2 text-text-light dark:text-text-dark placeholder-text-muted-light dark:placeholder-text-muted-dark focus:outline-none focus:ring-2 focus:ring-primary" placeholder="{{ _('Enter your password') }}" required minlength="8">
</div>
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Password must be at least 8 characters long') }}</p>
<label for="password_confirm" class="block mb-2 text-sm font-medium mt-4">{{ _('Confirm Password') }}</label>
<div class="relative">
<i class="fa-solid fa-lock absolute left-3 top-1/2 -translate-y-1/2 text-text-muted-light dark:text-text-muted-dark"></i>
<input type="password" name="password_confirm" id="password_confirm" autocomplete="new-password" class="w-full pl-10 bg-background-light dark:bg-gray-700 border border-border-light dark:border-border-dark rounded-lg px-3 py-2 text-text-light dark:text-text-dark placeholder-text-muted-light dark:placeholder-text-muted-dark focus:outline-none focus:ring-2 focus:ring-primary" placeholder="{{ _('Confirm your password') }}" required minlength="8">
</div>
<button type="submit" class="btn btn-primary w-full mt-6">{{ _('Set Password') }}</button>
</form>
</div>
</div>
</div>
<!-- Flash Messages (hidden; converted to toasts) -->
<div id="flash-messages-container" class="hidden">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert {% if category == 'success' %}alert-success{% elif category == 'error' %}alert-danger{% elif category == 'warning' %}alert-warning{% else %}alert-info{% endif %}" data-toast-message="{{ message }}" data-toast-type="{{ category }}"></div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/js/all.min.js"></script>
<script src="{{ url_for('static', filename='toast-notifications.js') }}"></script>
<script src="{{ url_for('static', filename='toast-manager.js') }}"></script>
<script src="{{ url_for('static', filename='error-handling-enhanced.js') }}"></script>
</body>
</html>

View File

@@ -0,0 +1,87 @@
{% extends "client_portal/base.html" %}
{% from "components/ui.html" import page_header %}
{% block title %}{{ _('Time Entries') }} - {{ _('Client Portal') }}{% endblock %}
{% block content %}
{% set breadcrumbs = [
{'text': _('Client Portal'), 'url': url_for('client_portal.dashboard')},
{'text': _('Time Entries')}
] %}
{{ page_header(
icon_class='fas fa-clock',
title_text=_('Time Entries'),
subtitle_text=_('Time entries for %(client_name)s projects', client_name=client.name),
breadcrumbs=breadcrumbs
) }}
<!-- Filters -->
<div class="bg-card-light dark:bg-card-dark p-4 rounded-lg shadow mb-6">
<form method="GET" class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label for="project_id" class="block text-sm font-medium mb-1">{{ _('Project') }}</label>
<select name="project_id" id="project_id" class="form-input">
<option value="">{{ _('All Projects') }}</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if selected_project_id == project.id %}selected{% endif %}>{{ project.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="date_from" class="block text-sm font-medium mb-1">{{ _('From Date') }}</label>
<input type="date" name="date_from" id="date_from" value="{{ date_from or '' }}" class="form-input">
</div>
<div>
<label for="date_to" class="block text-sm font-medium mb-1">{{ _('To Date') }}</label>
<input type="date" name="date_to" id="date_to" value="{{ date_to or '' }}" class="form-input">
</div>
<div class="flex items-end">
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 w-full">{{ _('Filter') }}</button>
</div>
</form>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
{% if time_entries %}
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead class="border-b border-border-light dark:border-border-dark">
<tr>
<th class="p-3">{{ _('Date') }}</th>
<th class="p-3">{{ _('Project') }}</th>
<th class="p-3">{{ _('User') }}</th>
<th class="p-3">{{ _('Start Time') }}</th>
<th class="p-3">{{ _('End Time') }}</th>
<th class="p-3">{{ _('Duration') }}</th>
<th class="p-3">{{ _('Description') }}</th>
</tr>
</thead>
<tbody>
{% for entry in time_entries %}
<tr class="border-b border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark">
<td class="p-3">{{ entry.start_time.strftime('%Y-%m-%d') }}</td>
<td class="p-3">{{ entry.project.name if entry.project else _('N/A') }}</td>
<td class="p-3">{{ entry.user.display_name if entry.user else _('N/A') }}</td>
<td class="p-3">{{ entry.start_time.strftime('%H:%M') }}</td>
<td class="p-3">{{ entry.end_time.strftime('%H:%M') if entry.end_time else '-' }}</td>
<td class="p-3 font-medium">{{ "%.2f"|format(entry.duration_hours) }}h</td>
<td class="p-3">{{ entry.description[:100] if entry.description else '-' }}{% if entry.description and entry.description|length > 100 %}...{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="mt-4 pt-4 border-t border-border-light dark:border-border-dark">
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">
{{ _('Total entries') }}: {{ time_entries|length }} |
{{ _('Total hours') }}: {{ "%.2f"|format(time_entries|sum(attribute='duration_hours')) }}h
</p>
</div>
{% else %}
<p class="text-text-muted-light dark:text-text-muted-dark text-center py-8">{{ _('No time entries found.') }}</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -69,11 +69,63 @@
</div>
</div>
<div class="mt-6 border-t border-border-light dark:border-border-dark pt-6">
<h3 class="text-lg font-semibold mb-4">{{ _('Client Portal Access') }}</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">
{{ _('Enable portal access for this client. Clients can log in with their own credentials to view projects, invoices, and time entries.') }}
</p>
<div class="space-y-4">
<div class="flex items-center">
<input type="checkbox" name="portal_enabled" id="portal_enabled" {% if client.portal_enabled %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" onchange="togglePortalFields()">
<label for="portal_enabled" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">{{ _('Enable Client Portal') }}</label>
</div>
<div id="portal_fields" style="display: {% if client.portal_enabled %}block{% else %}none{% endif %};">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="portal_username" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Portal Username') }}</label>
<input type="text" id="portal_username" name="portal_username" value="{{ request.form.get('portal_username', client.portal_username or '') }}" placeholder="{{ _('portal_username') }}" class="form-input">
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Unique username for portal login') }}</p>
</div>
<div>
<label for="portal_password" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Portal Password') }}</label>
<input type="password" id="portal_password" name="portal_password" value="" placeholder="{{ _('Leave empty to keep current password') }}" class="form-input">
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Set a new password or leave empty to keep current') }}</p>
</div>
</div>
{% if client.portal_enabled and client.portal_username %}
<div class="mt-2 text-sm text-text-muted-light dark:text-text-muted-dark">
<i class="fas fa-info-circle"></i> {{ _('Current portal username') }}: <strong>{{ client.portal_username }}</strong>
</div>
{% endif %}
</div>
</div>
</div>
<div class="mt-6 flex justify-end gap-3 border-t border-border-light dark:border-border-dark pt-4">
<a href="{{ url_for('clients.view_client', client_id=client.id) }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg">{{ _('Cancel') }}</a>
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">{{ _('Update Client') }}</button>
</div>
</form>
{% if client.portal_enabled and client.portal_username and client.email %}
<div class="mt-4 pt-4 border-t border-border-light dark:border-border-dark">
<form method="POST" action="{{ url_for('clients.send_portal_password_email', client_id=client.id) }}" id="send-email-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors text-sm">
<i class="fas fa-envelope mr-2"></i>{{ _('Send Password Setup Email') }}
</button>
</form>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-2">
{{ _('Send an email to %(email)s with a link to set their portal password.', email=client.email) }}
</p>
</div>
{% elif client.portal_enabled and client.portal_username and not client.email %}
<div class="mt-4 pt-4 border-t border-border-light dark:border-border-dark">
<p class="text-xs text-amber-600 dark:text-amber-400">
<i class="fas fa-exclamation-triangle mr-1"></i>{{ _('Email address is required to send password setup email. Please set the client email address above.') }}
</p>
</div>
{% endif %}
</div>
</div>
@@ -101,4 +153,16 @@
</div>
</div>
</div>
<script>
function togglePortalFields() {
const checkbox = document.getElementById('portal_enabled');
const container = document.getElementById('portal_fields');
if (checkbox.checked) {
container.style.display = 'block';
} else {
container.style.display = 'none';
}
}
</script>
{% endblock %}

View File

@@ -79,6 +79,7 @@
<th class="p-4" data-sortable>Email</th>
<th class="p-4" data-sortable>Status</th>
<th class="p-4" data-sortable>Projects</th>
<th class="p-4">Portal</th>
<th class="p-4">Actions</th>
</tr>
</thead>
@@ -109,6 +110,17 @@
<td class="p-4">
<span class="px-2 py-1 rounded-md text-xs font-medium bg-primary/10 text-primary">{{ client.active_projects }}/{{ client.total_projects }}</span>
</td>
<td class="p-4">
{% if client.portal_enabled %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300" title="Portal: {{ client.portal_username }}">
<i class="fas fa-building mr-1"></i>Enabled
</span>
{% else %}
<span class="px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">
Disabled
</span>
{% endif %}
</td>
<td class="p-4">
<a href="{{ url_for('clients.view_client', client_id=client.id) }}" class="text-primary hover:underline">View</a>
</td>

View File

@@ -47,7 +47,7 @@
<nav class="flex" aria-label="Breadcrumb">
<ol class="inline-flex items-center space-x-1 md:space-x-3">
<li class="inline-flex items-center">
<a href="{{ url_for('main.dashboard') }}" class="inline-flex items-center text-sm font-medium text-text-muted-light dark:text-text-muted-dark hover:text-primary transition-colors">
<a href="{% if get_current_client is defined and get_current_client() %}{{ url_for('client_portal.dashboard') }}{% else %}{{ url_for('main.dashboard') }}{% endif %}" class="inline-flex items-center text-sm font-medium text-text-muted-light dark:text-text-muted-dark hover:text-primary transition-colors">
<i class="fas fa-home mr-2"></i>
{{ _('Home') }}
</a>

View File

@@ -0,0 +1,121 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #3b82f6;
color: white;
padding: 20px;
text-align: center;
border-radius: 5px;
}
.content {
background-color: #f9fafb;
padding: 20px;
margin-top: 20px;
border-radius: 5px;
}
.info-box {
background-color: white;
padding: 15px;
margin: 15px 0;
border-left: 4px solid #3b82f6;
}
.info-box table {
width: 100%;
border-collapse: collapse;
}
.info-box td {
padding: 8px 0;
}
.info-box td:first-child {
font-weight: bold;
width: 40%;
}
.button {
display: inline-block;
padding: 12px 24px;
background-color: #10b981;
color: white;
text-decoration: none;
border-radius: 5px;
margin-top: 15px;
}
.footer {
text-align: center;
color: #6b7280;
font-size: 12px;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
}
.warning {
background-color: #fef3c7;
border-left: 4px solid #f59e0b;
padding: 12px;
margin: 15px 0;
border-radius: 4px;
}
</style>
</head>
<body>
<div class="header">
<h1>🔐 Client Portal Access</h1>
</div>
<div class="content">
<p>Hello {{ client.contact_person or client.name }},</p>
<p>You have been granted access to the <strong>TimeTracker Client Portal</strong> for <strong>{{ client.name }}</strong>.</p>
<div class="info-box">
<table>
<tr>
<td>Portal Username:</td>
<td><strong>{{ client.portal_username }}</strong></td>
</tr>
<tr>
<td>Client:</td>
<td>{{ client.name }}</td>
</tr>
</table>
</div>
<p>To complete your portal setup, please set your password by clicking the button below:</p>
<center>
<a href="{{ setup_url }}" class="button">
Set Your Password
</a>
</center>
<div class="warning">
<strong>⚠️ Important:</strong> This link will expire in 24 hours. If you need a new link, please contact your administrator.
</div>
<p>Once you've set your password, you'll be able to:</p>
<ul>
<li>View your projects and their status</li>
<li>Access invoices and payment information</li>
<li>Review time entries for your projects</li>
</ul>
<p>If you did not request this access, please contact your administrator immediately.</p>
</div>
<div class="footer">
<p>TimeTracker - Time Tracking & Project Management</p>
<p>This is an automated email. Please do not reply to this message.</p>
</div>
</body>
</html>

View File

@@ -16,8 +16,9 @@ def init_mail(app):
"""Initialize Flask-Mail with the app
Checks for database settings first, then falls back to environment variables.
Database settings persist between restarts and updates.
"""
# First, load defaults from environment variables
# First, load defaults from environment variables (as fallback)
app.config['MAIL_SERVER'] = os.getenv('MAIL_SERVER', 'localhost')
app.config['MAIL_PORT'] = int(os.getenv('MAIL_PORT', 587))
app.config['MAIL_USE_TLS'] = os.getenv('MAIL_USE_TLS', 'true').lower() == 'true'
@@ -28,6 +29,7 @@ def init_mail(app):
app.config['MAIL_MAX_EMAILS'] = int(os.getenv('MAIL_MAX_EMAILS', 100))
# Check if database settings should override environment variables
# Database settings persist between restarts and updates
try:
from app.models import Settings
from app import db
@@ -37,14 +39,15 @@ def init_mail(app):
db_config = settings.get_mail_config()
if db_config:
# Database settings take precedence
# Database settings take precedence and persist between restarts
app.config.update(db_config)
app.logger.info("Using database email configuration")
app.logger.info(f"Using database email configuration (persistent): {db_config.get('MAIL_SERVER')}:{db_config.get('MAIL_PORT')}")
else:
app.logger.info("Using environment variable email configuration")
app.logger.info("Using environment variable email configuration (database email not enabled)")
except Exception as e:
# If database is not available, fall back to environment variables
app.logger.debug(f"Could not load email settings from database: {e}")
app.logger.info("Using environment variable email configuration (database unavailable)")
mail.init_app(app)
return mail
@@ -54,6 +57,7 @@ def reload_mail_config(app):
"""Reload email configuration from database
Call this after updating email settings in the database to apply changes.
Database settings persist between restarts and updates.
"""
try:
from app.models import Settings
@@ -61,12 +65,15 @@ def reload_mail_config(app):
db_config = settings.get_mail_config()
if db_config:
# Update app configuration
# Update app configuration with latest database settings
app.config.update(db_config)
# Reinitialize mail with new config
# Reinitialize mail with new config (this ensures mail object uses latest settings)
mail.init_app(app)
app.logger.info(f"✓ Email configuration reloaded from database: {db_config.get('MAIL_SERVER')}:{db_config.get('MAIL_PORT')}")
return True
return False
else:
app.logger.info("No database email configuration found, using environment variables")
return False
except Exception as e:
app.logger.error(f"Failed to reload email configuration: {e}")
return False
@@ -81,6 +88,94 @@ def send_async_email(app, msg):
current_app.logger.error(f"Failed to send email: {e}")
def send_client_portal_password_setup_email(client, token):
"""Send password setup email to client
Args:
client: Client object
token: Password setup token
Returns:
bool: True if email sent successfully, False otherwise
"""
try:
if not client.email:
current_app.logger.warning(f"Cannot send password setup email to client {client.name}: no email address")
return False
# Always check database settings first (they take precedence)
from app.models import Settings
settings = Settings.get_settings()
db_config = settings.get_mail_config()
# Use database config if available, otherwise fall back to app config
if db_config:
mail_server = db_config.get('MAIL_SERVER')
mail_default_sender = db_config.get('MAIL_DEFAULT_SENDER')
# Reload mail config to ensure we're using latest database settings
reload_mail_config(current_app._get_current_object())
else:
mail_server = current_app.config.get('MAIL_SERVER')
mail_default_sender = current_app.config.get('MAIL_DEFAULT_SENDER')
# Check if email is configured
if not mail_server or mail_server == 'localhost':
current_app.logger.error("Mail server not configured. Cannot send password setup email.")
return False
# Generate password setup URL
setup_url = url_for('client_portal.set_password', token=token, _external=True)
# Render email template
html_body = render_template(
'email/client_portal_password_setup.html',
client=client,
setup_url=setup_url,
token=token
)
# Plain text version
text_body = f"""
Hello {client.name or client.contact_person or 'Client'},
You have been granted access to the TimeTracker Client Portal.
To set your password and access the portal, please click the following link:
{setup_url}
This link will expire in 24 hours.
If you did not request this access, please contact your administrator.
Best regards,
TimeTracker Team
"""
subject = f"Set Your Client Portal Password - {client.name}"
# Create message
msg = Message(
subject=subject,
recipients=[client.email],
body=text_body,
html=html_body,
sender=mail_default_sender or current_app.config.get('MAIL_DEFAULT_SENDER', 'noreply@timetracker.local')
)
# Send synchronously to catch errors
try:
mail.send(msg)
current_app.logger.info(f"Password setup email sent successfully to {client.email} for client {client.name}")
return True
except Exception as send_error:
current_app.logger.error(f"Failed to send password setup email: {send_error}")
return False
except Exception as e:
current_app.logger.error(f"Failed to prepare password setup email: {e}")
return False
def send_email(subject, recipients, text_body, html_body=None, sender=None, attachments=None):
"""Send an email
@@ -92,7 +187,20 @@ def send_email(subject, recipients, text_body, html_body=None, sender=None, atta
sender: Sender email address (optional, uses default if not provided)
attachments: List of (filename, content_type, data) tuples
"""
if not current_app.config.get('MAIL_SERVER'):
# Always check database settings first (they take precedence)
from app.models import Settings
settings = Settings.get_settings()
db_config = settings.get_mail_config()
# Use database config if available, otherwise fall back to app config
if db_config:
mail_server = db_config.get('MAIL_SERVER')
mail_default_sender = db_config.get('MAIL_DEFAULT_SENDER')
else:
mail_server = current_app.config.get('MAIL_SERVER')
mail_default_sender = current_app.config.get('MAIL_DEFAULT_SENDER')
if not mail_server or mail_server == 'localhost':
current_app.logger.warning("Mail server not configured, skipping email send")
return
@@ -105,7 +213,7 @@ def send_email(subject, recipients, text_body, html_body=None, sender=None, atta
recipients=recipients if isinstance(recipients, list) else [recipients],
body=text_body,
html=html_body,
sender=sender or current_app.config['MAIL_DEFAULT_SENDER']
sender=sender or mail_default_sender or current_app.config.get('MAIL_DEFAULT_SENDER', 'noreply@timetracker.local')
)
# Add attachments if provided

194
docs/CLIENT_PORTAL.md Normal file
View File

@@ -0,0 +1,194 @@
# Client Portal Feature
## Overview
The Client Portal provides a simplified, read-only interface for client users to view their projects, invoices, and time entries. This feature allows you to grant clients access to view their own data without exposing internal system functionality or other clients' information.
## Features
- **Dashboard**: Overview of projects, invoices, and time entries
- **Projects View**: List of all active projects with statistics
- **Invoices View**: List of invoices with filtering options (all, paid, unpaid, overdue)
- **Invoice Details**: Detailed view of individual invoices
- **Time Entries**: View time entries for projects with filtering capabilities
## Enabling Client Portal Access
### For Administrators
1. Navigate to **Admin****Users**
2. Click **Edit** on the user you want to grant portal access to
3. Scroll to the **Client Portal Access** section
4. Check **Enable Client Portal**
5. Select the **Client** from the dropdown
6. Click **Save**
The user will now have access to the client portal at `/client-portal`.
### User Requirements
For a user to access the client portal:
- `client_portal_enabled` must be `True`
- `client_id` must be set to a valid client ID
- The user must be active (`is_active = True`)
## Access Control
- Client portal users can only see data for their assigned client
- They cannot access:
- Other clients' data
- Internal admin functions
- User management
- System settings
- All portal routes require authentication and portal access verification
## Portal Routes
### Dashboard
- **URL**: `/client-portal` or `/client-portal/dashboard`
- **Description**: Overview page showing statistics and recent activity
### Projects
- **URL**: `/client-portal/projects`
- **Description**: List of all active projects for the client
### Invoices
- **URL**: `/client-portal/invoices`
- **Query Parameters**:
- `status`: Filter by status (`all`, `paid`, `unpaid`, `overdue`)
- **Description**: List of invoices with filtering options
### Invoice Detail
- **URL**: `/client-portal/invoices/<invoice_id>`
- **Description**: Detailed view of a specific invoice
### Time Entries
- **URL**: `/client-portal/time-entries`
- **Query Parameters**:
- `project_id`: Filter by project
- `date_from`: Filter entries from this date (YYYY-MM-DD)
- `date_to`: Filter entries to this date (YYYY-MM-DD)
- **Description**: List of time entries with filtering capabilities
## Database Schema
### User Model Changes
Two new fields were added to the `users` table:
```sql
client_portal_enabled BOOLEAN NOT NULL DEFAULT 0
client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL
```
### Migration
The migration `047_add_client_portal_fields.py` adds these fields. Run:
```bash
alembic upgrade head
```
## User Model Methods
### `is_client_portal_user` (property)
Returns `True` if the user has client portal access enabled and a client assigned.
```python
if user.is_client_portal_user:
# User has portal access
```
### `get_client_portal_data()`
Returns a dictionary containing all portal data for the user's assigned client:
```python
data = user.get_client_portal_data()
# Returns:
# {
# 'client': Client object,
# 'projects': [list of active projects],
# 'invoices': [list of invoices],
# 'time_entries': [list of time entries]
# }
```
Returns `None` if portal access is not enabled or no client is assigned.
## Admin Interface
### User List
The user list now displays a "Portal" badge for users with client portal access enabled, showing which client they're assigned to.
### User Edit Form
The user edit form includes a new **Client Portal Access** section with:
- Checkbox to enable/disable portal access
- Dropdown to select the assigned client
- Validation to ensure a client is selected when enabling portal access
## Security Considerations
1. **Access Control**: All portal routes verify that:
- User is authenticated
- User has `client_portal_enabled = True`
- User has a valid `client_id`
2. **Data Isolation**: Portal users can only see:
- Projects belonging to their assigned client
- Invoices for their assigned client
- Time entries for projects belonging to their assigned client
3. **Read-Only Access**: The portal is read-only - users cannot modify any data
4. **Invoice Access**: Users can only view invoices that belong to their assigned client
## Testing
Comprehensive tests are available in `tests/test_client_portal.py`:
- Model tests for user portal properties
- Route tests for access control
- Admin interface tests for enabling/disabling portal access
- Smoke tests for basic functionality
Run tests with:
```bash
pytest tests/test_client_portal.py -v
```
## Troubleshooting
### User Cannot Access Portal
1. Verify `client_portal_enabled` is `True` in the database
2. Verify `client_id` is set to a valid client ID
3. Verify the user is active (`is_active = True`)
4. Check that the client exists and is active
### Portal Shows No Data
1. Verify the client has active projects
2. Check that invoices exist for the client
3. Verify time entries exist for the client's projects
### Admin Cannot Enable Portal
1. Ensure a client is selected when enabling portal access
2. Verify the client exists and is active
3. Check for database errors in server logs
## Future Enhancements
Potential future improvements:
- Email notifications for new invoices
- PDF invoice downloads
- Export time entries to CSV
- Project status updates
- Comments/notes on projects
- Custom branding per client

View File

@@ -0,0 +1,50 @@
"""Add client portal fields to users table
Revision ID: 047
Revises: 046
Create Date: 2025-01-23
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '047'
down_revision = '046'
branch_labels = None
depends_on = None
def upgrade():
"""Add client_portal_enabled and client_id columns to users table"""
# Add client_portal_enabled column
op.add_column('users',
sa.Column('client_portal_enabled', sa.Boolean(), nullable=False, server_default='0')
)
# Add client_id column with foreign key
op.add_column('users',
sa.Column('client_id', sa.Integer(), nullable=True)
)
op.create_index('ix_users_client_id', 'users', ['client_id'])
op.create_foreign_key(
'fk_users_client_id',
'users', 'clients',
['client_id'], ['id'],
ondelete='SET NULL'
)
def downgrade():
"""Remove client_portal_enabled and client_id columns from users table"""
# Drop foreign key and index
op.drop_constraint('fk_users_client_id', 'users', type_='foreignkey')
op.drop_index('ix_users_client_id', 'users')
# Drop columns
op.drop_column('users', 'client_id')
op.drop_column('users', 'client_portal_enabled')

View File

@@ -0,0 +1,47 @@
"""Add client portal credentials to clients table
Revision ID: 048
Revises: 047
Create Date: 2025-01-23
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '048'
down_revision = '047'
branch_labels = None
depends_on = None
def upgrade():
"""Add portal_enabled, portal_username, and portal_password_hash columns to clients table"""
# Add portal_enabled column
op.add_column('clients',
sa.Column('portal_enabled', sa.Boolean(), nullable=False, server_default='0')
)
# Add portal_username column
op.add_column('clients',
sa.Column('portal_username', sa.String(length=80), nullable=True)
)
op.create_index('ix_clients_portal_username', 'clients', ['portal_username'], unique=True)
# Add portal_password_hash column
op.add_column('clients',
sa.Column('portal_password_hash', sa.String(length=255), nullable=True)
)
def downgrade():
"""Remove client portal columns from clients table"""
# Drop columns
op.drop_index('ix_clients_portal_username', 'clients')
op.drop_column('clients', 'portal_password_hash')
op.drop_column('clients', 'portal_username')
op.drop_column('clients', 'portal_enabled')

View File

@@ -0,0 +1,41 @@
"""Add password setup token fields to clients table
Revision ID: 049
Revises: 048
Create Date: 2025-01-23
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '049'
down_revision = '048'
branch_labels = None
depends_on = None
def upgrade():
"""Add password_setup_token and password_setup_token_expires columns to clients table"""
# Add password_setup_token column
op.add_column('clients',
sa.Column('password_setup_token', sa.String(length=100), nullable=True)
)
op.create_index('ix_clients_password_setup_token', 'clients', ['password_setup_token'])
# Add password_setup_token_expires column
op.add_column('clients',
sa.Column('password_setup_token_expires', sa.DateTime(), nullable=True)
)
def downgrade():
"""Remove password setup token columns from clients table"""
# Drop columns
op.drop_index('ix_clients_password_setup_token', 'clients')
op.drop_column('clients', 'password_setup_token_expires')
op.drop_column('clients', 'password_setup_token')

View File

@@ -7,7 +7,7 @@ from setuptools import setup, find_packages
setup(
name='timetracker',
version='3.9.0',
version='3.10.0',
packages=find_packages(),
include_package_data=True,
install_requires=[

348
tests/test_client_portal.py Normal file
View File

@@ -0,0 +1,348 @@
"""
Comprehensive tests for Client Portal feature.
This module tests:
- User model client portal fields and properties
- Client portal routes and access control
- Client portal data retrieval
- Admin interface for enabling/disabling portal access
"""
import pytest
from datetime import datetime, timedelta
from decimal import Decimal
from app.models import User, Client, Project, Invoice, InvoiceItem, TimeEntry
from app import db
# ============================================================================
# Model Tests
# ============================================================================
@pytest.mark.models
@pytest.mark.unit
class TestClientPortalUserModel:
"""Test User model client portal functionality"""
def test_user_client_portal_enabled_field(self, app, user):
"""Test client_portal_enabled field defaults to False"""
with app.app_context():
assert user.client_portal_enabled is False
def test_user_client_id_field(self, app, user):
"""Test client_id field defaults to None"""
with app.app_context():
assert user.client_id is None
def test_is_client_portal_user_property(self, app, user, client):
"""Test is_client_portal_user property"""
with app.app_context():
# Initially False
assert user.is_client_portal_user is False
# Enable portal but no client assigned
user.client_portal_enabled = True
assert user.is_client_portal_user is False
# Assign client
user.client_id = client.id
assert user.is_client_portal_user is True
def test_get_client_portal_data(self, app, user, client):
"""Test get_client_portal_data method"""
with app.app_context():
# No portal access
assert user.get_client_portal_data() is None
# Enable portal and assign client
user.client_portal_enabled = True
user.client_id = client.id
db.session.commit()
# Should return data structure
data = user.get_client_portal_data()
assert data is not None
assert 'client' in data
assert 'projects' in data
assert 'invoices' in data
assert 'time_entries' in data
assert data['client'] == client
def test_get_client_portal_data_with_projects(self, app, user, client):
"""Test get_client_portal_data includes projects"""
with app.app_context():
user.client_portal_enabled = True
user.client_id = client.id
# Create projects
project1 = Project(name="Project 1", client_id=client.id, status='active')
project2 = Project(name="Project 2", client_id=client.id, status='active')
project3 = Project(name="Project 3", client_id=client.id, status='inactive')
db.session.add_all([project1, project2, project3])
db.session.commit()
data = user.get_client_portal_data()
assert len(data['projects']) == 2 # Only active projects
assert project1 in data['projects']
assert project2 in data['projects']
assert project3 not in data['projects']
def test_get_client_portal_data_with_invoices(self, app, user, client):
"""Test get_client_portal_data includes invoices"""
with app.app_context():
user.client_portal_enabled = True
user.client_id = client.id
project = Project(name="Test Project", client_id=client.id)
db.session.add(project)
db.session.commit()
# Create invoices
invoice1 = Invoice(
invoice_number="INV-001",
project_id=project.id,
client_name=client.name,
client_id=client.id,
due_date=datetime.utcnow().date() + timedelta(days=30),
created_by=user.id,
total_amount=Decimal('100.00')
)
invoice2 = Invoice(
invoice_number="INV-002",
project_id=project.id,
client_name=client.name,
client_id=client.id,
due_date=datetime.utcnow().date() + timedelta(days=30),
created_by=user.id,
total_amount=Decimal('200.00')
)
db.session.add_all([invoice1, invoice2])
db.session.commit()
data = user.get_client_portal_data()
assert len(data['invoices']) == 2
assert invoice1 in data['invoices']
assert invoice2 in data['invoices']
def test_get_client_portal_data_with_time_entries(self, app, user, client):
"""Test get_client_portal_data includes time entries"""
with app.app_context():
user.client_portal_enabled = True
user.client_id = client.id
project = Project(name="Test Project", client_id=client.id)
db.session.add(project)
db.session.commit()
# Create time entries
entry1 = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=datetime.utcnow() - timedelta(hours=2),
end_time=datetime.utcnow(),
duration_seconds=7200
)
entry2 = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=datetime.utcnow() - timedelta(hours=1),
end_time=datetime.utcnow(),
duration_seconds=3600
)
db.session.add_all([entry1, entry2])
db.session.commit()
data = user.get_client_portal_data()
assert len(data['time_entries']) == 2
assert entry1 in data['time_entries']
assert entry2 in data['time_entries']
# ============================================================================
# Route Tests
# ============================================================================
@pytest.mark.routes
@pytest.mark.unit
class TestClientPortalRoutes:
"""Test client portal routes"""
def test_client_portal_dashboard_requires_access(self, app, client_fixture, user):
"""Test dashboard requires client portal access"""
with app.app_context():
# Login user without portal access
with client_fixture.session_transaction() as sess:
sess['_user_id'] = str(user.id)
response = client_fixture.get('/client-portal/dashboard')
assert response.status_code == 403
def test_client_portal_dashboard_with_access(self, app, client_fixture, user, client):
"""Test dashboard accessible with portal access"""
with app.app_context():
user.client_portal_enabled = True
user.client_id = client.id
db.session.commit()
with client_fixture.session_transaction() as sess:
sess['_user_id'] = str(user.id)
response = client_fixture.get('/client-portal/dashboard')
assert response.status_code == 200
assert b'Client Portal' in response.data
def test_client_portal_projects_route(self, app, client_fixture, user, client):
"""Test projects route"""
with app.app_context():
user.client_portal_enabled = True
user.client_id = client.id
db.session.commit()
with client_fixture.session_transaction() as sess:
sess['_user_id'] = str(user.id)
response = client_fixture.get('/client-portal/projects')
assert response.status_code == 200
def test_client_portal_invoices_route(self, app, client_fixture, user, client):
"""Test invoices route"""
with app.app_context():
user.client_portal_enabled = True
user.client_id = client.id
db.session.commit()
with client_fixture.session_transaction() as sess:
sess['_user_id'] = str(user.id)
response = client_fixture.get('/client-portal/invoices')
assert response.status_code == 200
def test_client_portal_time_entries_route(self, app, client_fixture, user, client):
"""Test time entries route"""
with app.app_context():
user.client_portal_enabled = True
user.client_id = client.id
db.session.commit()
with client_fixture.session_transaction() as sess:
sess['_user_id'] = str(user.id)
response = client_fixture.get('/client-portal/time-entries')
assert response.status_code == 200
def test_view_invoice_belongs_to_client(self, app, client_fixture, user, client):
"""Test viewing invoice requires it belongs to user's client"""
with app.app_context():
user.client_portal_enabled = True
user.client_id = client.id
# Create another client
other_client = Client(name="Other Client")
db.session.add(other_client)
project = Project(name="Test Project", client_id=client.id)
db.session.add(project)
db.session.commit()
# Create invoice for user's client
invoice = Invoice(
invoice_number="INV-001",
project_id=project.id,
client_name=client.name,
client_id=client.id,
due_date=datetime.utcnow().date() + timedelta(days=30),
created_by=user.id,
total_amount=Decimal('100.00')
)
db.session.add(invoice)
db.session.commit()
with client_fixture.session_transaction() as sess:
sess['_user_id'] = str(user.id)
# Should be able to view invoice
response = client_fixture.get(f'/client-portal/invoices/{invoice.id}')
assert response.status_code == 200
# ============================================================================
# Admin Interface Tests
# ============================================================================
@pytest.mark.routes
@pytest.mark.unit
class TestAdminClientPortalManagement:
"""Test admin interface for managing client portal access"""
def test_admin_can_enable_client_portal(self, app, admin_authenticated_client, user, client):
"""Test admin can enable client portal for user"""
with app.app_context():
response = admin_authenticated_client.post(
f'/admin/users/{user.id}/edit',
data={
'username': user.username,
'role': user.role,
'is_active': 'on' if user.is_active else '',
'client_portal_enabled': 'on',
'client_id': str(client.id),
'csrf_token': 'test-csrf-token'
},
follow_redirects=True
)
# Should redirect to users list
assert response.status_code == 200
# Verify user was updated
updated_user = User.query.get(user.id)
assert updated_user.client_portal_enabled is True
assert updated_user.client_id == client.id
def test_admin_can_disable_client_portal(self, app, admin_authenticated_client, user, client):
"""Test admin can disable client portal for user"""
with app.app_context():
# Enable portal first
user.client_portal_enabled = True
user.client_id = client.id
db.session.commit()
response = admin_authenticated_client.post(
f'/admin/users/{user.id}/edit',
data={
'username': user.username,
'role': user.role,
'is_active': 'on' if user.is_active else '',
'client_portal_enabled': '', # Not checked
'client_id': '',
'csrf_token': 'test-csrf-token'
},
follow_redirects=True
)
# Verify user was updated
updated_user = User.query.get(user.id)
assert updated_user.client_portal_enabled is False
assert updated_user.client_id is None
# ============================================================================
# Smoke Tests
# ============================================================================
@pytest.mark.smoke
@pytest.mark.unit
def test_client_portal_smoke(app, user, client):
"""Smoke test for client portal basic functionality"""
with app.app_context():
# Enable portal
user.client_portal_enabled = True
user.client_id = client.id
db.session.commit()
# Verify properties
assert user.is_client_portal_user is True
# Get portal data
data = user.get_client_portal_data()
assert data is not None
assert data['client'] == client