mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-02-16 10:38:45 -06:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
364
app/routes/client_portal.py
Normal 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
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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">
|
||||
|
||||
80
app/templates/client_portal/base.html
Normal file
80
app/templates/client_portal/base.html
Normal 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>
|
||||
|
||||
155
app/templates/client_portal/dashboard.html
Normal file
155
app/templates/client_portal/dashboard.html
Normal 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 %}
|
||||
|
||||
120
app/templates/client_portal/invoice_detail.html
Normal file
120
app/templates/client_portal/invoice_detail.html
Normal 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 %}
|
||||
|
||||
95
app/templates/client_portal/invoices.html
Normal file
95
app/templates/client_portal/invoices.html
Normal 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 %}
|
||||
|
||||
72
app/templates/client_portal/login.html
Normal file
72
app/templates/client_portal/login.html
Normal 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>
|
||||
53
app/templates/client_portal/projects.html
Normal file
53
app/templates/client_portal/projects.html
Normal 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 %}
|
||||
|
||||
82
app/templates/client_portal/set_password.html
Normal file
82
app/templates/client_portal/set_password.html
Normal 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>
|
||||
|
||||
87
app/templates/client_portal/time_entries.html
Normal file
87
app/templates/client_portal/time_entries.html
Normal 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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
121
app/templates/email/client_portal_password_setup.html
Normal file
121
app/templates/email/client_portal_password_setup.html
Normal 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>
|
||||
|
||||
@@ -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
194
docs/CLIENT_PORTAL.md
Normal 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
|
||||
|
||||
50
migrations/versions/047_add_client_portal_fields.py
Normal file
50
migrations/versions/047_add_client_portal_fields.py
Normal 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')
|
||||
|
||||
47
migrations/versions/048_add_client_portal_credentials.py
Normal file
47
migrations/versions/048_add_client_portal_credentials.py
Normal 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')
|
||||
|
||||
41
migrations/versions/049_add_client_password_setup_token.py
Normal file
41
migrations/versions/049_add_client_password_setup_token.py
Normal 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')
|
||||
|
||||
2
setup.py
2
setup.py
@@ -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
348
tests/test_client_portal.py
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user