From 39cf649f8e774291114db9f5644d3f7410d110f9 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Fri, 14 Nov 2025 15:15:38 +0100 Subject: [PATCH] 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 --- app/__init__.py | 2 + app/models/client.py | 119 +++++- app/models/user.py | 49 +++ app/routes/admin.py | 20 +- app/routes/client_portal.py | 364 ++++++++++++++++++ app/routes/clients.py | 92 +++++ app/templates/admin/user_form.html | 35 ++ app/templates/admin/users.html | 13 +- app/templates/client_portal/base.html | 80 ++++ app/templates/client_portal/dashboard.html | 155 ++++++++ .../client_portal/invoice_detail.html | 120 ++++++ app/templates/client_portal/invoices.html | 95 +++++ app/templates/client_portal/login.html | 72 ++++ app/templates/client_portal/projects.html | 53 +++ app/templates/client_portal/set_password.html | 82 ++++ app/templates/client_portal/time_entries.html | 87 +++++ app/templates/clients/edit.html | 64 +++ app/templates/clients/list.html | 12 + app/templates/components/ui.html | 2 +- .../email/client_portal_password_setup.html | 121 ++++++ app/utils/email.py | 126 +++++- docs/CLIENT_PORTAL.md | 194 ++++++++++ .../versions/047_add_client_portal_fields.py | 50 +++ .../048_add_client_portal_credentials.py | 47 +++ .../049_add_client_password_setup_token.py | 41 ++ setup.py | 2 +- tests/test_client_portal.py | 348 +++++++++++++++++ 27 files changed, 2426 insertions(+), 19 deletions(-) create mode 100644 app/routes/client_portal.py create mode 100644 app/templates/client_portal/base.html create mode 100644 app/templates/client_portal/dashboard.html create mode 100644 app/templates/client_portal/invoice_detail.html create mode 100644 app/templates/client_portal/invoices.html create mode 100644 app/templates/client_portal/login.html create mode 100644 app/templates/client_portal/projects.html create mode 100644 app/templates/client_portal/set_password.html create mode 100644 app/templates/client_portal/time_entries.html create mode 100644 app/templates/email/client_portal_password_setup.html create mode 100644 docs/CLIENT_PORTAL.md create mode 100644 migrations/versions/047_add_client_portal_fields.py create mode 100644 migrations/versions/048_add_client_portal_credentials.py create mode 100644 migrations/versions/049_add_client_password_setup_token.py create mode 100644 tests/test_client_portal.py diff --git a/app/__init__.py b/app/__init__.py index a2e4103..3607956 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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) diff --git a/app/models/client.py b/app/models/client.py index 7d8c5ed..f51497f 100644 --- a/app/models/client.py +++ b/app/models/client.py @@ -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 diff --git a/app/models/user.py b/app/models/user.py index cde94df..d38fd7f 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -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 + } diff --git a/app/routes/admin.py b/app/routes/admin.py index d2b1420..ba8039d 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -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//delete', methods=['POST']) @login_required diff --git a/app/routes/client_portal.py b/app/routes/client_portal.py new file mode 100644 index 0000000..62bf9e0 --- /dev/null +++ b/app/routes/client_portal.py @@ -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/') +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 + ) + diff --git a/app/routes/clients.py b/app/routes/clients.py index 935e5e2..2a8376c 100644 --- a/app/routes/clients.py +++ b/app/routes/clients.py @@ -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//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//archive', methods=['POST']) @login_required def archive_client(client_id): diff --git a/app/templates/admin/user_form.html b/app/templates/admin/user_form.html index 7c70ab7..25ffe36 100644 --- a/app/templates/admin/user_form.html +++ b/app/templates/admin/user_form.html @@ -31,6 +31,29 @@ +
+

Client Portal Access

+

+ Enable client portal access for this user. When enabled, the user will only see data for their assigned client. +

+
+
+ + +
+
+ + +

Select the client this user should have access to.

+
+
+
+

Advanced Permissions

@@ -55,4 +78,16 @@

+ + {% endblock %} diff --git a/app/templates/admin/users.html b/app/templates/admin/users.html index 15123fd..55f8f43 100644 --- a/app/templates/admin/users.html +++ b/app/templates/admin/users.html @@ -60,9 +60,16 @@ {% endif %} - - {{ 'Active' if user.is_active else 'Inactive' }} - +
+ + {{ 'Active' if user.is_active else 'Inactive' }} + + {% if user.client_portal_enabled %} + + Portal + + {% endif %} +
diff --git a/app/templates/client_portal/base.html b/app/templates/client_portal/base.html new file mode 100644 index 0000000..c020868 --- /dev/null +++ b/app/templates/client_portal/base.html @@ -0,0 +1,80 @@ + + + + + + {% block title %}{{ _('Client Portal') }} - {{ app_name }}{% endblock %} + + + + + + + + + + +
+
+
+
+

{{ _('Client Portal') }}

+ {% set current_client = get_current_client() %} + {% if current_client %} + {{ current_client.name }} + {% endif %} +
+ {% set current_client = get_current_client() %} + {% if current_client %} + + {% endif %} +
+
+
+ + +
+ {% from "components/ui.html" import breadcrumb_nav %} + {% block content %}{% endblock %} +
+ + + + + + + + + + diff --git a/app/templates/client_portal/dashboard.html b/app/templates/client_portal/dashboard.html new file mode 100644 index 0000000..c4e8704 --- /dev/null +++ b/app/templates/client_portal/dashboard.html @@ -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 +) }} + + +
+
+
+
+

{{ _('Active Projects') }}

+

{{ total_projects }}

+
+ +
+
+ +
+
+
+

{{ _('Total Hours') }}

+

{{ "%.2f"|format(total_hours) }}

+
+ +
+
+ +
+
+
+

{{ _('Total Invoices') }}

+

{{ total_invoices }}

+
+ +
+
+ +
+
+
+

{{ _('Outstanding') }}

+

{{ "%.2f"|format(unpaid_invoice_amount) }} {{ invoices[0].currency_code if invoices else 'EUR' }}

+
+ +
+
+
+ +
+ +
+
+

{{ _('Projects') }}

+ {{ _('View All') }} +
+ {% if projects %} +
+ {% for project in projects[:5] %} +
+
+

{{ project.name }}

+ {% if project.description %} +

{{ project.description[:50] }}{% if project.description|length > 50 %}...{% endif %}

+ {% endif %} +
+ {{ project.status|capitalize }} +
+ {% endfor %} +
+ {% else %} +

{{ _('No projects found.') }}

+ {% endif %} +
+ + +
+
+

{{ _('Recent Invoices') }}

+ {{ _('View All') }} +
+ {% if invoices %} +
+ {% for invoice in invoices[:5] %} +
+
+ {{ invoice.invoice_number }} +

{{ invoice.issue_date.strftime('%Y-%m-%d') }}

+
+
+

{{ "%.2f"|format(invoice.total_amount) }} {{ invoice.currency_code }}

+ + {{ invoice.payment_status|replace('_', ' ')|title }} + +
+
+ {% endfor %} +
+ {% else %} +

{{ _('No invoices found.') }}

+ {% endif %} +
+
+ + +
+
+

{{ _('Recent Time Entries') }}

+ {{ _('View All') }} +
+ {% if recent_time_entries %} +
+ + + + + + + + + + + + {% for entry in recent_time_entries[:10] %} + + + + + + + + {% endfor %} + +
{{ _('Date') }}{{ _('Project') }}{{ _('User') }}{{ _('Duration') }}{{ _('Description') }}
{{ entry.start_time.strftime('%Y-%m-%d') }}{{ entry.project.name if entry.project else _('N/A') }}{{ entry.user.display_name if entry.user else _('N/A') }}{{ "%.2f"|format(entry.duration_hours) }}h{{ entry.description[:50] if entry.description else '-' }}{% if entry.description and entry.description|length > 50 %}...{% endif %}
+
+ {% else %} +

{{ _('No time entries found.') }}

+ {% endif %} +
+{% endblock %} + diff --git a/app/templates/client_portal/invoice_detail.html b/app/templates/client_portal/invoice_detail.html new file mode 100644 index 0000000..d95c37f --- /dev/null +++ b/app/templates/client_portal/invoice_detail.html @@ -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 +) }} + +
+
+
+

{{ _('Client') }}

+

{{ invoice.client_name }}

+ {% if invoice.client_email %} +

{{ invoice.client_email }}

+ {% endif %} + {% if invoice.client_address %} +

{{ invoice.client_address }}

+ {% endif %} +
+
+

{{ _('Invoice Details') }}

+

{{ _('Issue Date') }}: {{ invoice.issue_date.strftime('%Y-%m-%d') }}

+

{{ _('Due Date') }}: {{ invoice.due_date.strftime('%Y-%m-%d') }}

+

{{ _('Status') }}: + + {{ invoice.payment_status|replace('_', ' ')|title }} + +

+ {% if invoice.is_overdue %} +

{{ _('This invoice is %(days)d days overdue.', days=invoice.days_overdue) }}

+ {% endif %} +
+
+ + {% if invoice.items %} +
+

{{ _('Invoice Items') }}

+
+ + + + + + + + + + + {% for item in invoice.items %} + + + + + + + {% endfor %} + +
{{ _('Description') }}{{ _('Quantity') }}{{ _('Unit Price') }}{{ _('Total') }}
{{ item.description }}{{ "%.2f"|format(item.quantity) }}{{ "%.2f"|format(item.unit_price) }} {{ invoice.currency_code }}{{ "%.2f"|format(item.total_amount) }} {{ invoice.currency_code }}
+
+
+ {% endif %} + +
+
+
+
+ {{ _('Subtotal') }}: + {{ "%.2f"|format(invoice.subtotal) }} {{ invoice.currency_code }} +
+ {% if invoice.tax_rate > 0 %} +
+ {{ _('Tax') }} ({{ "%.2f"|format(invoice.tax_rate) }}%): + {{ "%.2f"|format(invoice.tax_amount) }} {{ invoice.currency_code }} +
+ {% endif %} +
+ {{ _('Total') }}: + {{ "%.2f"|format(invoice.total_amount) }} {{ invoice.currency_code }} +
+ {% if invoice.payment_status != 'fully_paid' %} +
+ {{ _('Outstanding') }}: + {{ "%.2f"|format(invoice.outstanding_amount) }} {{ invoice.currency_code }} +
+ {% endif %} +
+
+
+ + {% if invoice.notes %} +
+

{{ _('Notes') }}

+

{{ invoice.notes }}

+
+ {% endif %} + + {% if invoice.terms %} +
+

{{ _('Terms') }}

+

{{ invoice.terms }}

+
+ {% endif %} +
+{% endblock %} + diff --git a/app/templates/client_portal/invoices.html b/app/templates/client_portal/invoices.html new file mode 100644 index 0000000..7fb9906 --- /dev/null +++ b/app/templates/client_portal/invoices.html @@ -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 +) }} + + + + +
+ {% if invoices %} +
+ + + + + + + + + + + + + {% for invoice in invoices %} + + + + + + + + + {% endfor %} + +
{{ _('Invoice Number') }}{{ _('Issue Date') }}{{ _('Due Date') }}{{ _('Amount') }}{{ _('Status') }}{{ _('Actions') }}
+ + {{ invoice.invoice_number }} + + {{ invoice.issue_date.strftime('%Y-%m-%d') }} + {{ invoice.due_date.strftime('%Y-%m-%d') }} + {% if invoice.is_overdue %} + ({{ invoice.days_overdue }} {{ _('days overdue') }}) + {% endif %} + {{ "%.2f"|format(invoice.total_amount) }} {{ invoice.currency_code }} + + {{ invoice.payment_status|replace('_', ' ')|title }} + + + + {{ _('View') }} + +
+
+ {% else %} +

{{ _('No invoices found.') }}

+ {% endif %} +
+{% endblock %} + diff --git a/app/templates/client_portal/login.html b/app/templates/client_portal/login.html new file mode 100644 index 0000000..20cb5b4 --- /dev/null +++ b/app/templates/client_portal/login.html @@ -0,0 +1,72 @@ + + + + + + {{ _('Client Portal Login') }} - {{ app_name }} + + + + + +
+
+ +
+

{{ _('Sign in to Client Portal') }}

+

{{ _('Enter your portal credentials to access your projects and invoices') }}

+
+ + {% if request.args.get('next') %} + + {% endif %} + + +
+ + +
+ + +
+ + +
+ + +
+
+
+
+ + + + + + + + + + diff --git a/app/templates/client_portal/projects.html b/app/templates/client_portal/projects.html new file mode 100644 index 0000000..d203b34 --- /dev/null +++ b/app/templates/client_portal/projects.html @@ -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 +) }} + +
+ {% if project_stats %} +
+ {% for stat in project_stats %} +
+
+

{{ stat.project.name }}

+ {{ stat.project.status|capitalize }} +
+ {% if stat.project.description %} +

{{ stat.project.description }}

+ {% endif %} +
+ {{ _('Total Hours') }}: + {{ "%.2f"|format(stat.total_hours) }}h +
+
+ {{ _('Time Entries') }}: + {{ stat.entry_count }} +
+ {% if stat.project.hourly_rate %} +
+ {{ _('Hourly Rate') }}: + {{ "%.2f"|format(stat.project.hourly_rate) }} {{ stat.project.client_obj.default_hourly_rate and 'EUR' or '' }} +
+ {% endif %} +
+ {% endfor %} +
+ {% else %} +

{{ _('No projects found.') }}

+ {% endif %} +
+{% endblock %} + diff --git a/app/templates/client_portal/set_password.html b/app/templates/client_portal/set_password.html new file mode 100644 index 0000000..425a3d9 --- /dev/null +++ b/app/templates/client_portal/set_password.html @@ -0,0 +1,82 @@ + + + + + + {{ _('Set Password') }} - {{ _('Client Portal') }} + + + + + +
+
+ +
+

{{ _('Set Your Password') }}

+

+ {{ _('Set a password for your client portal account') }} +

+ {% if client %} +
+

+ {{ _('Client') }}: {{ client.name }}
+ {{ _('Username') }}: {{ client.portal_username }} +

+
+ {% endif %} +
+ + + + +
+ + +
+

{{ _('Password must be at least 8 characters long') }}

+ + +
+ + +
+ + +
+
+
+
+ + + + + + + + + + + diff --git a/app/templates/client_portal/time_entries.html b/app/templates/client_portal/time_entries.html new file mode 100644 index 0000000..dc0c78a --- /dev/null +++ b/app/templates/client_portal/time_entries.html @@ -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 +) }} + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+ {% if time_entries %} +
+ + + + + + + + + + + + + + {% for entry in time_entries %} + + + + + + + + + + {% endfor %} + +
{{ _('Date') }}{{ _('Project') }}{{ _('User') }}{{ _('Start Time') }}{{ _('End Time') }}{{ _('Duration') }}{{ _('Description') }}
{{ entry.start_time.strftime('%Y-%m-%d') }}{{ entry.project.name if entry.project else _('N/A') }}{{ entry.user.display_name if entry.user else _('N/A') }}{{ entry.start_time.strftime('%H:%M') }}{{ entry.end_time.strftime('%H:%M') if entry.end_time else '-' }}{{ "%.2f"|format(entry.duration_hours) }}h{{ entry.description[:100] if entry.description else '-' }}{% if entry.description and entry.description|length > 100 %}...{% endif %}
+
+ +
+

+ {{ _('Total entries') }}: {{ time_entries|length }} | + {{ _('Total hours') }}: {{ "%.2f"|format(time_entries|sum(attribute='duration_hours')) }}h +

+
+ {% else %} +

{{ _('No time entries found.') }}

+ {% endif %} +
+{% endblock %} + diff --git a/app/templates/clients/edit.html b/app/templates/clients/edit.html index 6518ad6..8ff62a6 100644 --- a/app/templates/clients/edit.html +++ b/app/templates/clients/edit.html @@ -69,11 +69,63 @@
+
+

{{ _('Client Portal Access') }}

+

+ {{ _('Enable portal access for this client. Clients can log in with their own credentials to view projects, invoices, and time entries.') }} +

+
+
+ + +
+
+
+
+ + +

{{ _('Unique username for portal login') }}

+
+
+ + +

{{ _('Set a new password or leave empty to keep current') }}

+
+
+ {% if client.portal_enabled and client.portal_username %} +
+ {{ _('Current portal username') }}: {{ client.portal_username }} +
+ {% endif %} +
+
+
+
{{ _('Cancel') }}
+ + {% if client.portal_enabled and client.portal_username and client.email %} +
+
+ + +
+

+ {{ _('Send an email to %(email)s with a link to set their portal password.', email=client.email) }} +

+
+ {% elif client.portal_enabled and client.portal_username and not client.email %} +
+

+ {{ _('Email address is required to send password setup email. Please set the client email address above.') }} +

+
+ {% endif %} @@ -101,4 +153,16 @@ + + {% endblock %} diff --git a/app/templates/clients/list.html b/app/templates/clients/list.html index 2a470c8..4a6cdc0 100644 --- a/app/templates/clients/list.html +++ b/app/templates/clients/list.html @@ -79,6 +79,7 @@ Email Status Projects + Portal Actions @@ -109,6 +110,17 @@ {{ client.active_projects }}/{{ client.total_projects }} + + {% if client.portal_enabled %} + + Enabled + + {% else %} + + Disabled + + {% endif %} + View diff --git a/app/templates/components/ui.html b/app/templates/components/ui.html index c3f05fe..9196e99 100644 --- a/app/templates/components/ui.html +++ b/app/templates/components/ui.html @@ -47,7 +47,7 @@