05/22 Implement license version
This commit is contained in:
+67
-31
@@ -179,8 +179,12 @@ def create_app(config_name=None):
|
||||
except Exception:
|
||||
_zone = _ZI('UTC')
|
||||
now_local = _dt.now(_tz.utc).astimezone(_zone)
|
||||
license_status = current_app.config.get(
|
||||
'LICENSE_STATUS', {'valid': False, 'tier': 'community', 'reason': 'no_key'}
|
||||
)
|
||||
return dict(unread_notifications=unread, branding=branding,
|
||||
now_local=now_local, app_tz_name=_tz_name)
|
||||
now_local=now_local, app_tz_name=_tz_name,
|
||||
license=license_status)
|
||||
|
||||
# ── Security headers ──────────────────────────────────────────────────────
|
||||
@app.after_request
|
||||
@@ -199,6 +203,27 @@ def create_app(config_name=None):
|
||||
_seed_settings()
|
||||
_seed_ticket_templates()
|
||||
|
||||
# ── License validation ────────────────────────────────────────────────────
|
||||
# Validate on startup and cache the result in app.config so every request
|
||||
# can read it instantly without hitting disk or doing crypto again.
|
||||
# The result is also injected into Jinja2 templates via inject_globals.
|
||||
from app.services.license_service import validate_license
|
||||
_license_status = validate_license(app.config.get('LICENSE_KEY', ''))
|
||||
app.config['LICENSE_STATUS'] = _license_status
|
||||
_lic_tier = _license_status.get('tier', 'community')
|
||||
if _license_status.get('valid'):
|
||||
app.logger.info(
|
||||
f'[LICENSE] {_lic_tier.upper()} license valid for '
|
||||
f'{_license_status.get("customer")} — '
|
||||
f'expires {_license_status.get("expires_at")} '
|
||||
f'({_license_status.get("days_left")} days left)'
|
||||
)
|
||||
else:
|
||||
app.logger.warning(
|
||||
f'[LICENSE] No valid license ({_license_status.get("reason", "unknown")}) — '
|
||||
f'running in Community mode'
|
||||
)
|
||||
|
||||
# ── Background schedulers (SLA + Email Ingestion) ───────────────────────────
|
||||
# Both jobs share a single BackgroundScheduler instance to avoid duplicate
|
||||
# thread pools and simplify startup/shutdown. The scheduler is only started
|
||||
@@ -241,40 +266,51 @@ def _start_background_schedulers(app):
|
||||
|
||||
scheduler = BackgroundScheduler(daemon=True)
|
||||
|
||||
# SLA breach check — every 30 minutes
|
||||
scheduler.add_job(
|
||||
func = check_sla_breaches,
|
||||
trigger = IntervalTrigger(minutes=30),
|
||||
id = 'sla_check',
|
||||
name = 'SLA Breach Check',
|
||||
replace_existing = True,
|
||||
args = [app],
|
||||
)
|
||||
_lic = app.config.get('LICENSE_STATUS', {})
|
||||
_lic_tier = _lic.get('tier', 'community')
|
||||
_is_business = _lic_tier in ('business', 'enterprise')
|
||||
_is_enterprise = _lic_tier == 'enterprise'
|
||||
|
||||
# Inbound email ingestion — interval from SystemSetting
|
||||
scheduler.add_job(
|
||||
func = check_inbound_email,
|
||||
trigger = IntervalTrigger(minutes=email_interval),
|
||||
id = 'email_ingest',
|
||||
name = 'Inbound Email Ingestion',
|
||||
replace_existing = True,
|
||||
args = [app],
|
||||
)
|
||||
# SLA breach check — Business+ only, every 30 minutes
|
||||
if _is_business:
|
||||
scheduler.add_job(
|
||||
func = check_sla_breaches,
|
||||
trigger = IntervalTrigger(minutes=30),
|
||||
id = 'sla_check',
|
||||
name = 'SLA Breach Check',
|
||||
replace_existing = True,
|
||||
args = [app],
|
||||
)
|
||||
|
||||
# Weekly digest — every Monday at 08:00 UTC
|
||||
scheduler.add_job(
|
||||
func = send_weekly_digest,
|
||||
trigger = CronTrigger(day_of_week='mon', hour=8, minute=0),
|
||||
id = 'weekly_digest',
|
||||
name = 'Weekly IT Digest Email',
|
||||
replace_existing = True,
|
||||
args = [app],
|
||||
)
|
||||
# Inbound email ingestion — Enterprise only
|
||||
if _is_enterprise:
|
||||
scheduler.add_job(
|
||||
func = check_inbound_email,
|
||||
trigger = IntervalTrigger(minutes=email_interval),
|
||||
id = 'email_ingest',
|
||||
name = 'Inbound Email Ingestion',
|
||||
replace_existing = True,
|
||||
args = [app],
|
||||
)
|
||||
|
||||
# Weekly digest — Business+ only, every Monday at 08:00 UTC
|
||||
if _is_business:
|
||||
scheduler.add_job(
|
||||
func = send_weekly_digest,
|
||||
trigger = CronTrigger(day_of_week='mon', hour=8, minute=0),
|
||||
id = 'weekly_digest',
|
||||
name = 'Weekly IT Digest Email',
|
||||
replace_existing = True,
|
||||
args = [app],
|
||||
)
|
||||
|
||||
scheduler.start()
|
||||
app.logger.info('[SCHEDULER] APScheduler started — SLA check every 30 min, '
|
||||
f'email ingestion every {email_interval} min, '
|
||||
'weekly digest every Monday 08:00 UTC')
|
||||
app.logger.info(
|
||||
f'[SCHEDULER] APScheduler started — tier={_lic_tier} — '
|
||||
+ ('SLA check every 30 min, ' if _is_business else '')
|
||||
+ (f'email ingestion every {email_interval} min, ' if _is_enterprise else '')
|
||||
+ ('weekly digest every Monday 08:00 UTC' if _is_business else '')
|
||||
)
|
||||
except Exception as exc:
|
||||
app.logger.error(f'[SCHEDULER] Failed to start APScheduler: {exc}')
|
||||
|
||||
|
||||
@@ -1572,3 +1572,20 @@ def email_ingestion_run_now():
|
||||
except Exception as exc:
|
||||
logger.error(f'[ADMIN EMAIL INGEST] Manual run error: {exc}', exc_info=True)
|
||||
return jsonify(ok=False, message=f'Run failed: {exc}')
|
||||
|
||||
|
||||
@admin_bp.route('/license')
|
||||
@login_required
|
||||
@admin_required
|
||||
def license_page():
|
||||
from app.services.license_service import get_status, days_until_expiry, TIER_FEATURES
|
||||
status = get_status()
|
||||
days_left = days_until_expiry()
|
||||
tier = status.get('tier', 'community')
|
||||
features = TIER_FEATURES.get(tier, set())
|
||||
return render_template(
|
||||
'admin/license.html',
|
||||
status = status,
|
||||
days_left = days_left,
|
||||
features = features,
|
||||
)
|
||||
|
||||
@@ -130,6 +130,14 @@ def _call_groq(api_key, history, user_msg, kb_context=''):
|
||||
@login_required
|
||||
@limiter.limit('20 per minute; 100 per hour')
|
||||
def chat():
|
||||
from app.services.license_service import feature_enabled
|
||||
if not feature_enabled('chatbot'):
|
||||
return jsonify({
|
||||
'reply' : 'The AI chatbot is not available on the Community plan. '
|
||||
'Upgrade to a Business or Enterprise license to enable it.',
|
||||
'ticket': None,
|
||||
}), 403
|
||||
|
||||
data = request.get_json(force=True)
|
||||
history = data.get('history', []) # [{role, content}, ...]
|
||||
user_msg = data.get('message', '').strip()
|
||||
|
||||
+15
-2
@@ -999,6 +999,10 @@ def ticket_survey(token):
|
||||
@tickets_bp.route('/tickets/<int:ticket_id>/watch', methods=['POST'])
|
||||
@login_required
|
||||
def watch_ticket(ticket_id):
|
||||
from app.services.license_service import feature_enabled
|
||||
from flask import jsonify
|
||||
if not feature_enabled('watchers'):
|
||||
return jsonify({'error': 'Ticket watchers require a Business or Enterprise license.'}), 403
|
||||
ticket = db.session.get(Ticket, ticket_id) or abort(404)
|
||||
if not current_user.is_it_staff and ticket.created_by_id != current_user.id:
|
||||
abort(403)
|
||||
@@ -1006,19 +1010,21 @@ def watch_ticket(ticket_id):
|
||||
if not existing:
|
||||
db.session.add(TicketWatcher(ticket_id=ticket_id, user_id=current_user.id))
|
||||
db.session.commit()
|
||||
from flask import jsonify
|
||||
return jsonify({'watching': True})
|
||||
|
||||
|
||||
@tickets_bp.route('/tickets/<int:ticket_id>/unwatch', methods=['POST'])
|
||||
@login_required
|
||||
def unwatch_ticket(ticket_id):
|
||||
from app.services.license_service import feature_enabled
|
||||
from flask import jsonify
|
||||
if not feature_enabled('watchers'):
|
||||
return jsonify({'error': 'Ticket watchers require a Business or Enterprise license.'}), 403
|
||||
ticket = db.session.get(Ticket, ticket_id) or abort(404)
|
||||
if not current_user.is_it_staff and ticket.created_by_id != current_user.id:
|
||||
abort(403)
|
||||
TicketWatcher.query.filter_by(ticket_id=ticket_id, user_id=current_user.id).delete()
|
||||
db.session.commit()
|
||||
from flask import jsonify
|
||||
return jsonify({'watching': False})
|
||||
|
||||
|
||||
@@ -1027,6 +1033,13 @@ def unwatch_ticket(ticket_id):
|
||||
@tickets_bp.route('/tickets/<int:ticket_id>/log-time', methods=['POST'])
|
||||
@login_required
|
||||
def log_time(ticket_id):
|
||||
from app.services.license_service import feature_enabled
|
||||
if not feature_enabled('time_tracking'):
|
||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||
from flask import jsonify
|
||||
return jsonify({'error': 'Time tracking requires a Business or Enterprise license.'}), 403
|
||||
flash('Time tracking requires a Business or Enterprise license.', 'warning')
|
||||
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id))
|
||||
if not current_user.is_it_staff:
|
||||
abort(403)
|
||||
ticket = db.session.get(Ticket, ticket_id) or abort(404)
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
TechDesk License Service — offline RSA-signed key validation.
|
||||
|
||||
Key format: TDESK-<payload_b64>.<signature_b64>
|
||||
|
||||
payload_b64 = base64url( UTF-8 JSON bytes )
|
||||
signature_b64 = base64url( RSA-SHA256 signature of those bytes )
|
||||
|
||||
JSON payload fields:
|
||||
customer — company name
|
||||
email — contact email
|
||||
tier — "community" | "business" | "enterprise"
|
||||
issued_at — ISO date (YYYY-MM-DD)
|
||||
expires_at — ISO date (YYYY-MM-DD)
|
||||
|
||||
The public key below is the matching counterpart to the private key in
|
||||
license_server/private_key.pem. Replace the placeholder with the real key
|
||||
after running license_server/generate_keys.py for the first time.
|
||||
|
||||
Public key replacement steps:
|
||||
1. cd license_server && python generate_keys.py
|
||||
2. Copy the full contents of license_server/public_key.pem
|
||||
3. Replace the PUBLIC_KEY_PEM constant below with the copied text
|
||||
4. Restart the TechDesk app
|
||||
|
||||
The public key can only verify signatures — it cannot forge them.
|
||||
It is safe to embed in the application and ship to customers.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import date, datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Replace this with the output of license_server/generate_keys.py ──────────
|
||||
# After running generate_keys.py, copy the full contents of public_key.pem here.
|
||||
PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY-----
|
||||
REPLACE_WITH_REAL_PUBLIC_KEY_FROM_license_server/generate_keys.py
|
||||
-----END PUBLIC KEY-----"""
|
||||
|
||||
# ── Tier → feature set mapping ───────────────────────────────────────────────
|
||||
TIER_FEATURES: dict[str, set] = {
|
||||
'community' : set(),
|
||||
'business' : {'chatbot', 'sla', 'digest', 'watchers', 'time_tracking', 'surveys'},
|
||||
'enterprise': {'chatbot', 'sla', 'digest', 'watchers', 'time_tracking', 'surveys',
|
||||
'email_ingestion'},
|
||||
}
|
||||
|
||||
_COMMUNITY_STATUS = {
|
||||
'valid' : False,
|
||||
'tier' : 'community',
|
||||
'customer' : None,
|
||||
'email' : None,
|
||||
'issued_at' : None,
|
||||
'expires_at': None,
|
||||
'days_left' : None,
|
||||
}
|
||||
|
||||
|
||||
def validate_license(key_string: str) -> dict:
|
||||
"""Verify an RSA-signed TechDesk license key and return a status dict.
|
||||
|
||||
Returns a dict with keys:
|
||||
valid bool
|
||||
tier str ('community' | 'business' | 'enterprise')
|
||||
customer str | None
|
||||
email str | None
|
||||
issued_at str | None (YYYY-MM-DD)
|
||||
expires_at str | None (YYYY-MM-DD)
|
||||
days_left int | None (None when invalid/no key)
|
||||
reason str (human-readable; present when valid=False)
|
||||
"""
|
||||
if not key_string or not key_string.strip():
|
||||
return {**_COMMUNITY_STATUS, 'reason': 'no_key'}
|
||||
|
||||
key_string = key_string.strip()
|
||||
|
||||
if not key_string.startswith('TDESK-'):
|
||||
return {**_COMMUNITY_STATUS, 'reason': 'invalid_format'}
|
||||
|
||||
# Check if the public key is still a placeholder
|
||||
if 'REPLACE_WITH_REAL_PUBLIC_KEY' in PUBLIC_KEY_PEM:
|
||||
logger.warning('[LICENSE] Public key is a placeholder — all keys will fail validation. '
|
||||
'Run license_server/generate_keys.py and update PUBLIC_KEY_PEM.')
|
||||
return {**_COMMUNITY_STATUS, 'reason': 'no_public_key'}
|
||||
|
||||
try:
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
|
||||
raw = key_string[len('TDESK-'):]
|
||||
if '.' not in raw:
|
||||
return {**_COMMUNITY_STATUS, 'reason': 'invalid_format'}
|
||||
|
||||
payload_b64, sig_b64 = raw.split('.', 1)
|
||||
payload_bytes = base64.urlsafe_b64decode(_pad_b64(payload_b64))
|
||||
signature = base64.urlsafe_b64decode(_pad_b64(sig_b64))
|
||||
|
||||
public_key = serialization.load_pem_public_key(PUBLIC_KEY_PEM.strip().encode())
|
||||
public_key.verify(signature, payload_bytes, padding.PKCS1v15(), hashes.SHA256())
|
||||
|
||||
payload = json.loads(payload_bytes.decode('utf-8'))
|
||||
expires_at = payload.get('expires_at', '')
|
||||
expires = date.fromisoformat(expires_at)
|
||||
today = date.today()
|
||||
|
||||
if expires < today:
|
||||
return {
|
||||
**_COMMUNITY_STATUS,
|
||||
'expires_at': expires_at,
|
||||
'customer' : payload.get('customer'),
|
||||
'email' : payload.get('email'),
|
||||
'issued_at' : payload.get('issued_at'),
|
||||
'reason' : 'expired',
|
||||
}
|
||||
|
||||
days_left = (expires - today).days
|
||||
tier = payload.get('tier', 'community')
|
||||
if tier not in TIER_FEATURES:
|
||||
tier = 'community'
|
||||
|
||||
logger.info(
|
||||
f'[LICENSE] Valid {tier} license for {payload.get("customer")} '
|
||||
f'(expires {expires_at}, {days_left} days left)'
|
||||
)
|
||||
return {
|
||||
'valid' : True,
|
||||
'tier' : tier,
|
||||
'customer' : payload.get('customer'),
|
||||
'email' : payload.get('email'),
|
||||
'issued_at' : payload.get('issued_at'),
|
||||
'expires_at': expires_at,
|
||||
'days_left' : days_left,
|
||||
}
|
||||
|
||||
except InvalidSignature:
|
||||
logger.warning('[LICENSE] Key signature verification failed — key may be tampered.')
|
||||
return {**_COMMUNITY_STATUS, 'reason': 'invalid_signature'}
|
||||
except Exception as exc:
|
||||
logger.warning(f'[LICENSE] Key validation error: {exc}')
|
||||
return {**_COMMUNITY_STATUS, 'reason': 'invalid_format'}
|
||||
|
||||
|
||||
def get_status() -> dict:
|
||||
"""Return the cached license status stored in Flask app.config.
|
||||
|
||||
Must be called within an active Flask application context.
|
||||
Falls back to community status if not set (e.g. during testing).
|
||||
"""
|
||||
from flask import current_app
|
||||
return current_app.config.get('LICENSE_STATUS', {**_COMMUNITY_STATUS, 'reason': 'no_key'})
|
||||
|
||||
|
||||
def feature_enabled(feature_name: str) -> bool:
|
||||
"""Return True if the current license tier grants access to feature_name.
|
||||
|
||||
Must be called within an active Flask application context.
|
||||
"""
|
||||
status = get_status()
|
||||
tier = status.get('tier', 'community')
|
||||
return feature_name in TIER_FEATURES.get(tier, set())
|
||||
|
||||
|
||||
def days_until_expiry() -> 'int | None':
|
||||
"""Return days remaining on the current license, or None if invalid/community."""
|
||||
status = get_status()
|
||||
if not status.get('valid'):
|
||||
return None
|
||||
return status.get('days_left')
|
||||
|
||||
|
||||
def _pad_b64(s: str) -> str:
|
||||
"""Add base64url padding so Python's decoder accepts it."""
|
||||
return s + '=' * (-len(s) % 4)
|
||||
@@ -587,6 +587,10 @@ def send_weekly_digest(app):
|
||||
from app.models import Ticket, TicketStatus, TicketPriority
|
||||
|
||||
with app.app_context():
|
||||
from app.services.license_service import feature_enabled
|
||||
if not feature_enabled('digest'):
|
||||
logger.debug('[DIGEST] Skipped — digest feature not enabled on current license tier.')
|
||||
return
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
week_ago = now - timedelta(days=7)
|
||||
|
||||
@@ -143,6 +143,10 @@ def check_sla_breaches(app):
|
||||
is re-opened, ensuring fresh notifications if the issue resurfaces.
|
||||
"""
|
||||
with app.app_context():
|
||||
from app.services.license_service import feature_enabled
|
||||
if not feature_enabled('sla'):
|
||||
logger.debug('[SLA] Skipped — SLA feature not enabled on current license tier.')
|
||||
return
|
||||
try:
|
||||
_run_sla_check(app)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}License{% endblock %}
|
||||
{% block page_title %}License{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row g-4">
|
||||
|
||||
{# ── Status card ─────────────────────────────────────────────────────────── #}
|
||||
<div class="col-12 col-lg-5">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><i class="bi bi-key me-2"></i>License Status</div>
|
||||
<div class="card-body">
|
||||
|
||||
{# Tier badge #}
|
||||
{% if status.tier == 'enterprise' %}
|
||||
<span class="badge bg-purple fs-6 mb-3" style="background:#6d28d9!important;">
|
||||
<i class="bi bi-award me-1"></i>Enterprise
|
||||
</span>
|
||||
{% elif status.tier == 'business' %}
|
||||
<span class="badge bg-primary fs-6 mb-3">
|
||||
<i class="bi bi-briefcase me-1"></i>Business
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary fs-6 mb-3">
|
||||
<i class="bi bi-person me-1"></i>Community
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if status.valid %}
|
||||
<p class="mb-1"><i class="bi bi-check-circle-fill text-success me-2"></i>
|
||||
<strong>License is active</strong></p>
|
||||
{% if status.customer %}
|
||||
<p class="mb-1 text-muted small">
|
||||
<i class="bi bi-building me-1"></i>{{ status.customer }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if status.email %}
|
||||
<p class="mb-1 text-muted small">
|
||||
<i class="bi bi-envelope me-1"></i>{{ status.email }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if status.issued_at %}
|
||||
<p class="mb-1 text-muted small">
|
||||
<i class="bi bi-calendar-check me-1"></i>Issued: {{ status.issued_at }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if status.expires_at %}
|
||||
<p class="mb-1 text-muted small">
|
||||
<i class="bi bi-calendar-x me-1"></i>Expires: {{ status.expires_at }}
|
||||
{% if days_left is not none %}
|
||||
{% if days_left < 30 %}
|
||||
<span class="badge bg-warning text-dark ms-1">{{ days_left }}d left</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success ms-1">{{ days_left }}d left</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<p class="mb-2">
|
||||
<i class="bi bi-x-circle-fill text-danger me-2"></i>
|
||||
<strong>No active license</strong>
|
||||
</p>
|
||||
<p class="text-muted small mb-0">
|
||||
{% if status.get('reason') == 'expired' %}
|
||||
Your license expired on <strong>{{ status.expires_at }}</strong>.
|
||||
Contact your vendor to renew.
|
||||
{% elif status.get('reason') == 'no_key' %}
|
||||
No license key is configured.
|
||||
Add <code>LICENSE_KEY=TDESK-...</code> to your <code>.env</code> file and restart.
|
||||
{% elif status.get('reason') == 'no_public_key' %}
|
||||
The application's public key has not been configured yet.
|
||||
Contact your system administrator.
|
||||
{% else %}
|
||||
The license key is invalid or has been tampered with.
|
||||
Contact your vendor for a replacement key.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Feature checklist ───────────────────────────────────────────────────── #}
|
||||
<div class="col-12 col-lg-7">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><i class="bi bi-grid-3x3-gap me-2"></i>Feature Access</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Feature</th>
|
||||
<th class="text-center">Community</th>
|
||||
<th class="text-center">Business</th>
|
||||
<th class="text-center">Enterprise</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% set rows = [
|
||||
('Core ticketing, comments, attachments', True, True, True),
|
||||
('Knowledge base', True, True, True),
|
||||
('User management, bulk actions, CSV export', True, True, True),
|
||||
('AI Chatbot (Groq)', False, True, True),
|
||||
('SLA tracking & breach notifications', False, True, True),
|
||||
('Weekly IT digest email', False, True, True),
|
||||
('Ticket watchers', False, True, True),
|
||||
('Time tracking', False, True, True),
|
||||
('Satisfaction surveys', False, True, True),
|
||||
('Email ingestion (IMAP)', False, False, True),
|
||||
] %}
|
||||
{% for label, com, biz, ent in rows %}
|
||||
<tr>
|
||||
<td style="font-size:.9rem;">{{ label | safe }}</td>
|
||||
{% for flag, tier_name in [(com, 'community'), (biz, 'business'), (ent, 'enterprise')] %}
|
||||
<td class="text-center">
|
||||
{% set is_current = (status.tier == tier_name) %}
|
||||
{% if flag %}
|
||||
<i class="bi bi-check-circle-fill {% if is_current %}text-success{% else %}text-muted{% endif %}"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-dash-circle text-muted opacity-25"></i>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Setup instructions ──────────────────────────────────────────────────── #}
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header"><i class="bi bi-info-circle me-2"></i>How to Apply a License Key</div>
|
||||
<div class="card-body">
|
||||
<ol class="mb-0" style="font-size:.9rem;line-height:2;">
|
||||
<li>Obtain a license key from your vendor (format: <code>TDESK-...</code>).</li>
|
||||
<li>Open the <code>.env</code> file in your TechDesk installation directory.</li>
|
||||
<li>Add or update the line: <code>LICENSE_KEY=TDESK-your-key-here</code></li>
|
||||
<li>Restart the TechDesk service: <code>sudo systemctl restart gunicorn</code></li>
|
||||
<li>Return to this page to confirm the license is active.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
+33
-1
@@ -1162,6 +1162,10 @@
|
||||
<li><a href="{{ url_for('admin.settings') }}" class="{{ 'active' if request.endpoint == 'admin.settings' }}">
|
||||
<i class="bi bi-gear"></i> Settings
|
||||
</a></li>
|
||||
<li><a href="{{ url_for('admin.license_page') }}" class="{{ 'active' if request.endpoint == 'admin.license_page' }}">
|
||||
<i class="bi bi-key"></i> License
|
||||
{% if not license.valid %}<span class="badge bg-warning text-dark ms-1" style="font-size:.65rem;">!</span>{% endif %}
|
||||
</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
@@ -1232,6 +1236,32 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
{% if current_user.is_authenticated and current_user.is_admin %}
|
||||
{% if not license.valid %}
|
||||
<div class="alert alert-warning alert-dismissible mb-3" role="alert">
|
||||
<i class="bi bi-key me-2"></i>
|
||||
{% if license.get('reason') == 'expired' %}
|
||||
Your TechDesk license expired on <strong>{{ license.expires_at }}</strong>.
|
||||
Features are restricted to Community tier.
|
||||
{% elif license.get('reason') == 'no_key' %}
|
||||
No license key configured. TechDesk is running in <strong>Community mode</strong>.
|
||||
{% else %}
|
||||
License key is invalid. TechDesk is running in <strong>Community mode</strong>.
|
||||
{% endif %}
|
||||
<a href="{{ url_for('admin.license_page') }}" class="alert-link ms-1">View license details →</a>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% elif license.days_left is not none and license.days_left < 30 %}
|
||||
<div class="alert alert-info alert-dismissible mb-3" role="alert">
|
||||
<i class="bi bi-key me-2"></i>
|
||||
Your TechDesk license expires in <strong>{{ license.days_left }} day{{ 's' if license.days_left != 1 }}</strong>.
|
||||
<a href="{{ url_for('admin.license_page') }}" class="alert-link ms-1">View license →</a>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1251,7 +1281,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Chat FAB ── -->
|
||||
<!-- ── Chat FAB (Business+ license required) ── -->
|
||||
{% if license.tier in ('business', 'enterprise') %}
|
||||
<div id="chat-fab" onclick="toggleChat()" title="IT Assistant">
|
||||
<i class="bi bi-robot"></i>
|
||||
</div>
|
||||
@@ -1276,6 +1307,7 @@
|
||||
<button class="chat-send" onclick="sendChat()"><i class="bi bi-send-fill"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<!-- Not authenticated — minimal layout -->
|
||||
|
||||
@@ -828,7 +828,8 @@ function buildCommentEl(c) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Watch / Unwatch -->
|
||||
<!-- Watch / Unwatch (Business+ license required) -->
|
||||
{% if license.tier in ('business', 'enterprise') %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-2 px-3 d-flex align-items-center justify-content-between">
|
||||
<span style="font-size:13px;color:var(--muted);">
|
||||
@@ -843,9 +844,10 @@ function buildCommentEl(c) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Time Tracking (IT staff only) -->
|
||||
{% if current_user.is_it_staff %}
|
||||
<!-- Time Tracking (IT staff only, Business+ license required) -->
|
||||
{% if current_user.is_it_staff and license.tier in ('business', 'enterprise') %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex align-items-center justify-content-between">
|
||||
<span><i class="bi bi-clock me-2"></i>Time Logged</span>
|
||||
|
||||
Reference in New Issue
Block a user