Compare commits

..
10 Commits
23 changed files with 763 additions and 526 deletions
+10 -1
View File
@@ -70,7 +70,13 @@ audiences:
│ └── versions/ # Alembic migration scripts
├── gunicorn.conf.py
├── run.py # Entry point — calls eventlet.monkey_patch() first
── .env # Secrets (not committed)
── .env # Secrets (not committed)
└── license_server/ # Vendor-only — NOT shipped to customers
├── app.py # Internal Flask UI for generating license keys
├── generate_keys.py # One-time RSA key pair generator
├── requirements.txt # flask + cryptography
├── private_key.pem # RSA private key — NEVER commit or ship
└── licenses.db # SQLite log of issued keys
```
---
@@ -504,6 +510,8 @@ Migrations live in `migrations/versions/`. The chain is:
└── 004_widen_system_setting_value — system_settings.value VARCHAR(500) → TEXT
└── 005_add_watchers_and_time_entries — creates ticket_watchers
and time_entries tables
└── 006_add_sla_breach_notified — adds tickets.sla_breach_notified;
retires sla_notified_tickets setting
```
**Rules:**
@@ -839,6 +847,7 @@ When an error is reported:
| `003_render_comments` | Backfill comment bodies to HTML; widen `alembic_version.version_num` to `VARCHAR(64)` |
| `004_widen_system_setting_value` | Widen `system_settings.value` from `VARCHAR(500)` to `TEXT` |
| `005_add_watchers_and_time_entries` | Create `ticket_watchers` (UniqueConstraint + index) and `time_entries` (index) tables |
| `006_add_sla_breach_notified` | Add `tickets.sla_breach_notified` boolean; backfill from and retire the `sla_notified_tickets` SystemSetting |
## 21. Browser Tab Notification Counter (added 2026-04-17)
+2 -2
View File
@@ -147,7 +147,7 @@ def create_app(config_name=None):
# ── Context processors ────────────────────────────────────────────────────
@app.context_processor
def inject_globals():
from flask import has_request_context
from flask import has_request_context, current_app
from flask_login import current_user
from app.models import SystemSetting
unread = 0
@@ -369,7 +369,7 @@ def _seed_settings():
('email_ingestion_host', '', 'IMAP server hostname (e.g. imap.gmail.com)'),
('email_ingestion_port', '993', 'IMAP SSL port'),
('email_ingestion_user', '', 'Mailbox username / email address'),
('email_ingestion_password', '', 'Mailbox password (stored in plaintext — use a dedicated app password)'),
('email_ingestion_password', '', 'Mailbox password (encrypted at rest — use a dedicated app password)'),
('email_ingestion_folder', 'INBOX', 'IMAP folder to watch for new mail'),
('email_ingestion_move_to', 'Processed', 'IMAP folder to move processed mail into'),
('email_ingestion_interval', '5', 'Poll interval in minutes'),
+6
View File
@@ -113,6 +113,12 @@ class Ticket(db.Model):
closed_at = db.Column(db.DateTime)
due_date = db.Column(db.DateTime)
# Set once an SLA breach notification has been sent for this ticket, so
# the 30-minute scheduler job doesn't re-notify every run. Cleared when
# the ticket resolves/closes/re-opens (see sla_service.clear_sla_notification)
# so a re-opened ticket gets a fresh alert if it breaches again.
sla_breach_notified = db.Column(db.Boolean, nullable=False, default=False, server_default='0')
ai_generated = db.Column(db.Boolean, default=False)
internal_notes = db.Column(db.Text)
resolution_notes = db.Column(db.Text)
+28 -11
View File
@@ -354,7 +354,7 @@ def edit_user(user_id):
new_pw = request.form.get('new_password', '')
if new_pw:
confirm_pw = request.form.get('confirm_password', '')
pw_error = validate_password(new_pw, confirm_pw)
pw_error = validate_password(new_pw, confirm_pw, user.password_hash)
if pw_error:
flash(pw_error, 'danger')
return render_template('admin/edit_user.html', user=user, roles=_roles())
@@ -617,15 +617,26 @@ def kb_delete_attachment(article_id, att_id):
att = KBAttachment.query.filter_by(id=att_id, article_id=article_id).first_or_404()
upload_dir = current_app.config['UPLOAD_FOLDER']
filepath = os.path.join(upload_dir, att.stored_name)
if os.path.exists(filepath):
os.remove(filepath)
log_action(current_user.id, 'kb_attachment_delete', 'kb_attachment', att.id,
f'article_id={article_id} filename={att.filename}')
logger.info(f'[KB ATTACHMENT DELETE] att_id={att.id} article_id={article_id} by user_id={current_user.id}')
att_id_val, filename = att.id, att.filename
# DB row is the source of truth — delete and commit it first, then remove
# the physical file. If the process dies mid-operation this leaves at
# worst an orphan file on disk (harmless, cleanable later) rather than a
# DB row pointing at a file that's already gone (a broken download link).
log_action(current_user.id, 'kb_attachment_delete', 'kb_attachment', att_id_val,
f'article_id={article_id} filename={filename}')
db.session.delete(att)
db.session.commit()
logger.info(f'[KB ATTACHMENT DELETE] att_id={att_id_val} article_id={article_id} by user_id={current_user.id}')
if os.path.exists(filepath):
try:
os.remove(filepath)
except OSError as exc:
logger.warning(f'[KB ATTACHMENT DELETE] Could not remove file {filepath}: {exc}')
# Return JSON so the edit page can remove the row without a full reload
return jsonify({'ok': True, 'att_id': att.id})
return jsonify({'ok': True, 'att_id': att_id_val})
@@ -1318,10 +1329,12 @@ def settings():
'email_ingestion_move_to' : request.form.get('email_ingestion_move_to', 'Processed').strip(),
'email_ingestion_interval': request.form.get('email_ingestion_interval', '5').strip(),
}
# Password: only update if a new value was provided
# Password: only update if a new value was provided. Encrypted
# at rest — see app/services/crypto_service.py.
new_pw = request.form.get('email_ingestion_password', '').strip()
if new_pw:
fields['email_ingestion_password'] = new_pw
from app.services.crypto_service import encrypt_secret
fields['email_ingestion_password'] = encrypt_secret(new_pw)
changes = []
for key, value in fields.items():
@@ -1412,7 +1425,10 @@ def settings():
'host' : SystemSetting.get('email_ingestion_host', ''),
'port' : SystemSetting.get('email_ingestion_port', '993'),
'user' : SystemSetting.get('email_ingestion_user', ''),
'password': SystemSetting.get('email_ingestion_password', ''),
# The stored password is never sent back to the browser — the field
# is always rendered blank with a placeholder indicating whether one
# is already configured (see settings.html).
'has_password': bool(SystemSetting.get('email_ingestion_password', '')),
'folder' : SystemSetting.get('email_ingestion_folder', 'INBOX'),
'move_to' : SystemSetting.get('email_ingestion_move_to', 'Processed'),
'interval' : SystemSetting.get('email_ingestion_interval', '5'),
@@ -1477,7 +1493,8 @@ def email_ingestion_test():
# Fall back to the stored password if the admin left the field blank
if not password:
password = SystemSetting.get('email_ingestion_password', '')
from app.services.crypto_service import decrypt_secret
password = decrypt_secret(SystemSetting.get('email_ingestion_password', ''))
if not host or not user or not password:
return jsonify(ok=False, message='Host, username, and password are required.')
+3 -3
View File
@@ -173,7 +173,7 @@ def profile():
logger.info(f'[AUTH AVATAR UPLOAD] user_id={current_user.id} file={stored_name}')
if new_pw:
pw_error = validate_password(new_pw, confirm_pw)
pw_error = validate_password(new_pw, confirm_pw, current_user.password_hash)
if pw_error:
flash(pw_error, 'danger')
return render_template('auth/profile.html')
@@ -236,12 +236,12 @@ def reset_password(token):
if request.method == 'POST':
password = request.form.get('password', '')
confirm = request.form.get('confirm_password', '')
pw_error = validate_password(password, confirm)
user = token_row.user
pw_error = validate_password(password, confirm, user.password_hash)
if pw_error:
flash(pw_error, 'danger')
return render_template('auth/reset_password.html', token=token)
user = token_row.user
user.set_password(password)
db.session.delete(token_row) # single-use — delete immediately
log_action(user.id, 'password_reset', 'user', user.id)
-16
View File
@@ -86,22 +86,6 @@ def save_attachment(file, ticket_id=None, comment_id=None, uploader_id=None):
)
db.session.add(att)
return att
filename = secure_filename(file.filename)
ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
stored_name = f"{uuid.uuid4().hex}.{ext}"
upload_dir = current_app.config['UPLOAD_FOLDER']
file.save(os.path.join(upload_dir, stored_name))
att = Attachment(
ticket_id = ticket_id,
comment_id = comment_id,
filename = filename,
stored_name= stored_name,
file_size = os.path.getsize(os.path.join(upload_dir, stored_name)),
mime_type = file.content_type,
uploaded_by= uploader_id,
)
db.session.add(att)
return att
# ─── Dashboard ────────────────────────────────────────────────────────────────
+45
View File
@@ -0,0 +1,45 @@
"""
Symmetric encryption for secrets stored in SystemSetting (currently just
the IMAP mailbox password used by email ingestion).
The Fernet key is derived from SECRET_KEY rather than a separately managed
key, so no extra key-rotation story is needed for a single-tenant on-prem
app rotating SECRET_KEY (which already invalidates sessions) also
invalidates encrypted secrets, which is an acceptable trade-off here.
"""
import base64
import hashlib
from cryptography.fernet import Fernet, InvalidToken
from flask import current_app
def _fernet() -> Fernet:
secret = current_app.config['SECRET_KEY'].encode()
key = base64.urlsafe_b64encode(hashlib.sha256(secret).digest())
return Fernet(key)
def encrypt_secret(plain: str) -> str:
"""Encrypt a secret for storage. Empty input passes through unchanged."""
if not plain:
return ''
return _fernet().encrypt(plain.encode()).decode()
def decrypt_secret(stored: str) -> str:
"""Decrypt a secret previously written by encrypt_secret().
Falls back to returning the value unchanged if it isn't a valid Fernet
token. This covers values saved before encryption was introduced, so
existing configurations (e.g. email ingestion set up before this change)
keep working without a manual data migration the value is simply
re-encrypted the next time it's saved through the settings form.
"""
if not stored:
return ''
try:
return _fernet().decrypt(stored.encode()).decode()
except (InvalidToken, ValueError):
return stored
+2 -1
View File
@@ -295,7 +295,8 @@ def _run_ingestion(app):
host = _get_setting('email_ingestion_host', '')
port = int(_get_setting('email_ingestion_port', '993'))
username = _get_setting('email_ingestion_user', '')
password = _get_setting('email_ingestion_password', '')
from app.services.crypto_service import decrypt_secret
password = decrypt_secret(_get_setting('email_ingestion_password', ''))
folder = _get_setting('email_ingestion_folder', 'INBOX')
move_to = _get_setting('email_ingestion_move_to', 'Processed')
+7 -28
View File
@@ -1,30 +1,5 @@
"""
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
@@ -34,10 +9,14 @@ 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
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxAMwjewlFaHpP+e69jM8
2I630u4B66kHcnqen2bxK65CZxvTBlLy2k/sm4zVVFyX0nf/9bDDio0UUTJrKWsp
kWV91Ag6FbvMgiJixfg5cZmpOd/abMs2OXAT/GZY+TLoszduchLLbkZjfDlhJzlt
D0MmAU9IY+zm9kW/YLWujFE37CFAfMLkDasQ7ZKKqjzQH6jOi8MoccI5zk8GlpoA
zlppk4dyQejmVDIXItg/0WVxaUa26tFafpU1RROlOKsw1lFXlWRQkc/uuJCdM943
PX0y1ZCCoNuOKofTIeBT506V7oM4g0rHGmD7esQJsz2S5E2rLetyLwcMKNjzo4a4
dwIDAQAB
-----END PUBLIC KEY-----"""
# ── Tier → feature set mapping ───────────────────────────────────────────────
+19 -38
View File
@@ -16,11 +16,11 @@ Responsibilities
Notification suppression
------------------------
A dedicated SystemSetting key sla_notified_tickets stores a
comma-separated list of ticket IDs that have already received a breach
notification. When a ticket is resolved or closed the ID is removed
from the list so the suppression does not persist across re-opens
(edge case: ticket re-opened after resolution unlikely but handled).
Ticket.sla_breach_notified is a per-ticket boolean flag, set once a breach
notification has been sent so the 30-minute scheduler run doesn't re-notify
every time. When a ticket is resolved or closed the flag is cleared so
the suppression does not persist across re-opens (edge case: ticket
re-opened after resolution unlikely but handled).
Design notes
------------
@@ -137,10 +137,10 @@ def check_sla_breaches(app):
"""Scheduled job: find overdue tickets and notify responsible parties.
Safe to call repeatedly already-notified tickets are suppressed via
the sla_notified_tickets SystemSetting key. The suppression list is
cleared for a ticket when it transitions to resolved/closed (handled by
the update_ticket route clearing it on status change) or when the ticket
is re-opened, ensuring fresh notifications if the issue resurfaces.
Ticket.sla_breach_notified. The flag is cleared for a ticket when it
transitions to resolved/closed (handled by the update_ticket route) or
when the ticket is re-opened, ensuring fresh notifications if the issue
resurfaces.
"""
with app.app_context():
from app.services.license_service import feature_enabled
@@ -155,24 +155,18 @@ def check_sla_breaches(app):
def _run_sla_check(app):
from app import db
from app.models import (
Ticket, TicketStatus, User, UserRole,
NotificationType, SystemSetting,
)
from app.models import Ticket, TicketStatus, User, UserRole, NotificationType
from app.services.notification_service import create_notification, send_email
from flask import render_template_string
now = datetime.utcnow()
# ── Load suppression list ──────────────────────────────────────────────────
raw = SystemSetting.get('sla_notified_tickets', '')
already_notified = set(int(x) for x in raw.split(',') if x.strip().isdigit())
# ── Query overdue open tickets ─────────────────────────────────────────────
# ── Query overdue, not-yet-notified open tickets ───────────────────────────
overdue = Ticket.query.filter(
Ticket.status.in_([TicketStatus.OPEN, TicketStatus.IN_PROGRESS]),
Ticket.due_date.isnot(None),
Ticket.due_date < now,
Ticket.sla_breach_notified.is_(False),
).all()
if not overdue:
@@ -185,9 +179,6 @@ def _run_sla_check(app):
newly_notified = []
for ticket in overdue:
if ticket.id in already_notified:
continue # already sent — skip
ticket_url = f'{base_url}/tickets/{ticket.id}'
overdue_mins = int((now - ticket.due_date).total_seconds() / 60)
overdue_label = (
@@ -263,18 +254,11 @@ def _run_sla_check(app):
if it_dept_email:
send_email(subject, [it_dept_email], html)
ticket.sla_breach_notified = True
newly_notified.append(ticket.id)
db.session.commit()
# ── Update suppression list ────────────────────────────────────────────────
if newly_notified:
updated = already_notified | set(newly_notified)
SystemSetting.set(
'sla_notified_tickets',
','.join(str(i) for i in sorted(updated)),
'Comma-separated ticket IDs that have received SLA breach notifications',
)
db.session.commit()
logger.info(
f'[SLA] Notified {len(newly_notified)} breach(es): '
f'{[str(i) for i in newly_notified]}'
@@ -282,17 +266,14 @@ def _run_sla_check(app):
def clear_sla_notification(ticket_id: int):
"""Remove a ticket from the SLA suppression list.
"""Clear the SLA breach-notified flag for a ticket.
Call this when a ticket is resolved, closed, or re-opened so that
subsequent breaches (if the ticket re-opens) trigger fresh alerts.
Callers are responsible for committing after calling this function.
"""
from app.models import SystemSetting
raw = SystemSetting.get('sla_notified_tickets', '')
current = set(int(x) for x in raw.split(',') if x.strip().isdigit())
current.discard(ticket_id)
SystemSetting.set(
'sla_notified_tickets',
','.join(str(i) for i in sorted(current)),
)
from app import db
from app.models import Ticket
ticket = db.session.get(Ticket, ticket_id)
if ticket:
ticket.sla_breach_notified = False
+18 -2
View File
@@ -13,7 +13,7 @@ logger = logging.getLogger(__name__)
# ─── Password Validation ──────────────────────────────────────────────────────
def validate_password(password: str, confirm: str) -> str | None:
def validate_password(password: str, confirm: str, current_hash: str | None = None) -> str | None:
"""Validate a new password and its confirmation field.
Returns an error message string if validation fails, or None if the
@@ -23,27 +23,43 @@ def validate_password(password: str, confirm: str) -> str | None:
Rules
-----
- Password and confirmation must match.
- Minimum length: 8 characters.
- Length: 8-128 characters.
- Must contain at least one uppercase letter (A-Z).
- Must contain at least one digit (0-9).
- Must contain at least one special character (!@#$%^&* etc.).
- If current_hash is given, the new password must differ from it.
Parameters
----------
password : str the candidate password (plain text)
confirm : str the confirmation field value
current_hash : str|None the user's existing password_hash, if this is
a password *change* (profile, admin edit,
reset) rather than new-account creation.
When given, re-using the current password is
rejected.
"""
import re
if password != confirm:
return 'Passwords do not match.'
if len(password) < 8:
return 'Password must be at least 8 characters.'
# Upper bound prevents a pathologically long input from driving up the
# cost of password hashing (which scales with input size) on the server.
if len(password) > 128:
return 'Password must be no more than 128 characters.'
if not re.search(r'[A-Z]', password):
return 'Password must contain at least one uppercase letter.'
if not re.search(r'\d', password):
return 'Password must contain at least one number.'
if not re.search(r'[!@#$%^&*()\-_=+\[\]{};:\'",.<>?/\\|`~]', password):
return 'Password must contain at least one special character.'
# Hash comparison is the most expensive check, so it runs last —
# only after every cheap format check has already passed.
if current_hash:
from werkzeug.security import check_password_hash
if check_password_hash(current_hash, password):
return 'New password must be different from your current password.'
return None
+54
View File
@@ -0,0 +1,54 @@
/*
* Shared password-strength UI: show/hide toggle, strength meter, rule
* checklist, and confirm-match indicator. Used on the register, profile,
* admin create/edit user, and password reset forms.
*
* Relies on the --bg, --border, --border2, --muted, --success, --danger,
* --accent CSS custom properties already defined by each page's theme
* (see base.html for admin/auth pages, or the page's own :root block for
* the standalone auth pages).
*/
.pw-wrap { position: relative; }
.pw-wrap input { padding-right: 42px; }
.pw-toggle {
position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
background: none; border: none; color: var(--muted); cursor: pointer;
font-size: 16px; padding: 4px; line-height: 1; transition: color .15s;
}
.pw-toggle:hover { color: var(--accent); }
.pw-strength-wrap { margin-top: 8px; }
.pw-strength-track {
height: 4px; background: var(--border); border-radius: 2px;
overflow: hidden; margin-bottom: 6px;
}
.pw-strength-fill {
height: 100%; border-radius: 2px; width: 0%;
transition: width .3s ease, background .3s ease;
}
.pw-strength-label {
font-size: 11px; font-weight: 600; letter-spacing: .3px;
display: flex; align-items: center; gap: 5px; transition: color .3s;
}
.pw-rules {
margin-top: 8px; display: grid; grid-template-columns: 1fr 1fr; gap: 3px 12px;
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
padding: 10px 12px;
}
.pw-rule { display: flex; align-items: center; gap: 5px; font-size: 11.5px; color: var(--muted); transition: color .2s; }
.pw-rule .ri { font-size: 13px; width: 14px; text-align: center; transition: color .2s, transform .15s; }
.pw-rule.met { color: var(--success); }
.pw-rule.met .ri { color: var(--success); transform: scale(1.1); }
.pw-rule.unmet .ri { color: var(--border2); }
.pw-match {
font-size: 11.5px; margin-top: 6px; display: flex; align-items: center; gap: 5px;
font-weight: 600; letter-spacing: .2px; min-height: 18px; transition: color .2s;
}
.pw-match.ok { color: var(--success); }
.pw-match.fail { color: var(--danger); }
.pw-input.valid { border-color: var(--success) !important; box-shadow: 0 0 0 3px rgba(5,150,105,.08) !important; }
.pw-input.invalid { border-color: var(--danger) !important; box-shadow: 0 0 0 3px rgba(220,38,38,.08) !important; }
+176
View File
@@ -0,0 +1,176 @@
/**
* Shared password-strength UI: live rule checklist, strength meter,
* confirm-password match indicator, and show/hide toggle.
*
* The RULES below mirror validate_password() in
* app/services/validation_service.py exactly. This is the single
* client-side copy of that policy every password form (register,
* profile, admin create/edit user, password reset) includes this file
* instead of re-implementing the checks, so the two can never drift
* out of sync with each other. The server remains the source of truth;
* this file only gives the user live feedback before they submit.
*/
(function (global) {
'use strict';
const RULES = {
len : pw => pw.length >= 8 && pw.length <= 128,
upper : pw => /[A-Z]/.test(pw),
digit : pw => /\d/.test(pw),
special: pw => /[!@#$%^&*()\-_=+\[\]{};:'",.<>?/\\|`~]/.test(pw),
};
const LEVELS = [
{ label: 'Very Weak', color: '#ef4444', pct: 12 },
{ label: 'Weak', color: '#f97316', pct: 30 },
{ label: 'Fair', color: '#eab308', pct: 52 },
{ label: 'Good', color: '#22c55e', pct: 76 },
{ label: 'Strong', color: '#059669', pct: 100 },
];
function score(pw) {
let s = 0;
if (RULES.len(pw)) s++;
if (RULES.upper(pw)) s++;
if (RULES.digit(pw)) s++;
if (RULES.special(pw)) s++;
if (pw.length >= 16) s++; // bonus for extra length
return s;
}
function allRulesMet(pw) {
return RULES.len(pw) && RULES.upper(pw) && RULES.digit(pw) && RULES.special(pw);
}
function byId(idOrEl) {
return typeof idOrEl === 'string' ? document.getElementById(idOrEl) : idOrEl;
}
function setRuleEl(el, met) {
if (!el) return;
el.classList.toggle('met', met);
el.classList.toggle('unmet', !met);
const icon = el.querySelector('.ri');
if (icon) {
icon.innerHTML = met
? '<i class="bi bi-check-circle-fill"></i>'
: '<i class="bi bi-circle"></i>';
}
}
function toggleVisibility(inputEl, btnEl) {
if (!inputEl || !btnEl) return;
const showing = inputEl.type === 'password';
inputEl.type = showing ? 'text' : 'password';
const icon = btnEl.querySelector('i');
if (icon) icon.className = showing ? 'bi bi-eye-slash' : 'bi bi-eye';
btnEl.title = showing ? 'Hide password' : 'Show password';
}
/**
* Wire up a password field with a live strength bar + rule checklist,
* and optionally a confirm-password field with a match indicator.
*
* opts:
* password - id/element of the password <input> (required)
* confirm - id/element of the confirm-password <input> (optional)
* strengthBar - id/element of the strength bar fill
* strengthLabel - id/element of the strength label text
* strengthWrap - id/element to show/hide with the strength bar
* rulesWrap - id/element of the checklist container to show/hide
* rules - { len, upper, digit, special } -> ids/elements of checklist rows
* matchMsg - id/element for the confirm-match message
* toggles - [{ input, button }, ...] show/hide password buttons
* onChange() - called after every recompute (e.g. to gate a submit button)
*/
function init(opts) {
const pwEl = byId(opts.password);
if (!pwEl) return null;
const confirmEl = byId(opts.confirm);
const barEl = byId(opts.strengthBar);
const labelEl = byId(opts.strengthLabel);
const strengthWrapEl = byId(opts.strengthWrap);
const rulesWrapEl = byId(opts.rulesWrap);
const matchMsgEl = byId(opts.matchMsg);
const ruleEls = {};
if (opts.rules) {
Object.keys(opts.rules).forEach(k => { ruleEls[k] = byId(opts.rules[k]); });
}
function renderStrength() {
const pw = pwEl.value;
if (pw.length === 0) {
if (barEl) barEl.style.width = '0%';
if (labelEl) { labelEl.textContent = ''; labelEl.style.color = ''; }
if (strengthWrapEl) strengthWrapEl.style.display = 'none';
if (rulesWrapEl) rulesWrapEl.style.display = 'none';
pwEl.classList.remove('valid', 'invalid');
} else {
if (strengthWrapEl) strengthWrapEl.style.display = 'block';
if (rulesWrapEl) rulesWrapEl.style.display = 'grid';
Object.keys(ruleEls).forEach(k => setRuleEl(ruleEls[k], RULES[k](pw)));
const s = score(pw);
const lvl = LEVELS[Math.max(0, s - 1)] || LEVELS[0];
if (barEl) { barEl.style.width = lvl.pct + '%'; barEl.style.background = lvl.color; }
if (labelEl) {
const shieldIcon = s >= 4 ? 'shield-fill' : s >= 2 ? 'shield-half' : 'shield';
labelEl.innerHTML = `<i class="bi bi-${shieldIcon}"></i> ${lvl.label}`;
labelEl.style.color = lvl.color;
}
const met = allRulesMet(pw);
pwEl.classList.toggle('valid', met);
pwEl.classList.toggle('invalid', !met);
}
renderMatch();
if (opts.onChange) opts.onChange();
}
function renderMatch() {
if (!confirmEl) return;
const pw = pwEl.value;
const pw2 = confirmEl.value;
if (pw2.length === 0) {
if (matchMsgEl) { matchMsgEl.innerHTML = ''; matchMsgEl.classList.remove('ok', 'fail'); }
confirmEl.classList.remove('valid', 'invalid');
return;
}
const ok = pw === pw2;
if (matchMsgEl) {
matchMsgEl.innerHTML = ok
? '<i class="bi bi-check-circle-fill"></i> Passwords match'
: '<i class="bi bi-x-circle-fill"></i> Passwords do not match';
matchMsgEl.classList.toggle('ok', ok);
matchMsgEl.classList.toggle('fail', !ok);
}
confirmEl.classList.toggle('valid', ok);
confirmEl.classList.toggle('invalid', !ok);
}
pwEl.addEventListener('input', renderStrength);
if (confirmEl) {
confirmEl.addEventListener('input', function () {
renderMatch();
if (opts.onChange) opts.onChange();
});
}
if (opts.toggles) {
opts.toggles.forEach(({ input, button }) => {
const inputEl = byId(input);
const buttonEl = byId(button);
if (buttonEl) buttonEl.addEventListener('click', () => toggleVisibility(inputEl, buttonEl));
});
}
return { allRulesMet, score, renderStrength, renderMatch };
}
global.PasswordStrength = { init, RULES, allRulesMet, score };
})(window);
+61 -74
View File
@@ -2,6 +2,10 @@
{% block title %}Create User{% endblock %}
{% block page_title %}User Management{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/password-strength.css') }}"/>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-7">
@@ -97,36 +101,59 @@
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label">Password *</label>
<div style="position:relative;">
<input type="password" class="form-control" name="password"
id="pw-field" required minlength="8"
<div class="pw-wrap">
<input type="password" class="form-control pw-input" name="password"
id="pw-field" required minlength="8" maxlength="128"
placeholder="Min. 8 characters"
style="padding-right:44px;"/>
<button type="button" onclick="togglePw('pw-field','eye1')"
style="position:absolute;right:10px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--muted);cursor:pointer;padding:4px;">
<i class="bi bi-eye" id="eye1"></i>
autocomplete="new-password"/>
<button type="button" class="pw-toggle" id="pw-field-toggle"
title="Show / hide password" tabindex="-1">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
<div class="col-md-6">
<label class="form-label">Confirm Password *</label>
<div style="position:relative;">
<input type="password" class="form-control" name="confirm_password"
id="pw-field2" required minlength="8"
<div class="pw-wrap">
<input type="password" class="form-control pw-input" name="confirm_password"
id="pw-field2" required minlength="8" maxlength="128"
placeholder="Repeat password"
style="padding-right:44px;"/>
<button type="button" onclick="togglePw('pw-field2','eye2')"
style="position:absolute;right:10px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--muted);cursor:pointer;padding:4px;">
<i class="bi bi-eye" id="eye2"></i>
autocomplete="new-password"/>
<button type="button" class="pw-toggle" id="pw-field2-toggle"
title="Show / hide password" tabindex="-1">
<i class="bi bi-eye"></i>
</button>
</div>
<div class="pw-match" id="pw-match-msg" aria-live="polite" aria-atomic="true"></div>
</div>
<div class="col-12">
<!-- Password strength bar -->
<div style="height:4px;background:var(--border);border-radius:2px;margin-top:4px;">
<div id="pw-strength-bar" style="height:100%;border-radius:2px;width:0%;transition:width .3s,background .3s;"></div>
<!-- Strength bar -->
<div class="pw-strength-wrap" id="pw-strength-wrap" style="display:none;">
<div class="pw-strength-track">
<div class="pw-strength-fill" id="pw-strength-bar"></div>
</div>
<div class="pw-strength-label" id="pw-strength-label"></div>
</div>
<!-- Rule checklist — mirrors server-side validate_password() rules -->
<div class="pw-rules" id="pw-rules-box" style="display:none;" aria-live="polite" aria-atomic="true">
<div class="pw-rule unmet" id="pw-rule-len">
<span class="ri"><i class="bi bi-circle"></i></span>
<span>8+ characters</span>
</div>
<div class="pw-rule unmet" id="pw-rule-upper">
<span class="ri"><i class="bi bi-circle"></i></span>
<span>Uppercase letter</span>
</div>
<div class="pw-rule unmet" id="pw-rule-digit">
<span class="ri"><i class="bi bi-circle"></i></span>
<span>Number</span>
</div>
<div class="pw-rule unmet" id="pw-rule-special">
<span class="ri"><i class="bi bi-circle"></i></span>
<span>Special character</span>
</div>
</div>
<div id="pw-strength-label" style="font-size:11px;color:var(--muted);margin-top:4px;"></div>
</div>
</div>
@@ -146,71 +173,31 @@
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/password-strength.js') }}"></script>
<script>
// ── Show/hide password ────────────────────────────────────────────────────────
function togglePw(fieldId, iconId) {
const field = document.getElementById(fieldId);
const icon = document.getElementById(iconId);
if (field.type === 'password') {
field.type = 'text';
icon.className = 'bi bi-eye-slash';
} else {
field.type = 'password';
icon.className = 'bi bi-eye';
}
}
// ── Password strength indicator ───────────────────────────────────────────────
document.getElementById('pw-field').addEventListener('input', function () {
const val = this.value;
let score = 0;
if (val.length >= 8) score++;
if (val.length >= 12) score++;
if (/[A-Z]/.test(val) && /[a-z]/.test(val))score++;
if (/[0-9]/.test(val)) score++;
if (/[^A-Za-z0-9]/.test(val)) score++;
const bar = document.getElementById('pw-strength-bar');
const label = document.getElementById('pw-strength-label');
const levels = [
{ pct: '20%', bg: '#dc2626', text: 'Very weak' },
{ pct: '40%', bg: '#d97706', text: 'Weak' },
{ pct: '60%', bg: '#ca8a04', text: 'Fair' },
{ pct: '80%', bg: '#16a34a', text: 'Strong' },
{ pct: '100%', bg: '#059669', text: 'Very strong' },
];
if (val.length === 0) {
bar.style.width = '0%';
label.textContent = '';
return;
}
const lvl = levels[Math.min(score - 1, 4)] || levels[0];
bar.style.width = lvl.pct;
bar.style.background = lvl.bg;
label.textContent = lvl.text;
label.style.color = lvl.bg;
const pwStrength = PasswordStrength.init({
password : 'pw-field',
confirm : 'pw-field2',
strengthBar : 'pw-strength-bar',
strengthLabel: 'pw-strength-label',
strengthWrap : 'pw-strength-wrap',
rulesWrap : 'pw-rules-box',
rules: { len: 'pw-rule-len', upper: 'pw-rule-upper', digit: 'pw-rule-digit', special: 'pw-rule-special' },
matchMsg : 'pw-match-msg',
toggles: [
{ input: 'pw-field', button: 'pw-field-toggle' },
{ input: 'pw-field2', button: 'pw-field2-toggle' },
],
});
// ── Client-side password match check before submit ────────────────────────────
// ── Block submit on a real mismatch (server re-validates regardless) ──────────
document.querySelector('form').addEventListener('submit', function (e) {
const pw = document.getElementById('pw-field').value;
const pw2 = document.getElementById('pw-field2').value;
if (pw !== pw2) {
e.preventDefault();
document.getElementById('pw-field2').style.borderColor = 'var(--danger)';
const msg = document.createElement('div');
msg.style.cssText = 'font-size:12px;color:var(--danger);margin-top:4px;';
msg.textContent = 'Passwords do not match.';
const existing = document.getElementById('pw-match-msg');
if (existing) existing.remove();
msg.id = 'pw-match-msg';
document.getElementById('pw-field2').insertAdjacentElement('afterend', msg);
pwStrength.renderMatch();
}
});
document.getElementById('pw-field2').addEventListener('input', function () {
this.style.borderColor = '';
const msg = document.getElementById('pw-match-msg');
if (msg) msg.remove();
});
</script>
{% endblock %}
+86 -1
View File
@@ -2,6 +2,10 @@
{% block title %}Edit User{% endblock %}
{% block page_title %}Edit User{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/password-strength.css') }}"/>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-6">
@@ -44,7 +48,58 @@
<div class="col-12"><hr style="border-color:var(--border);"/></div>
<div class="col-md-6">
<label class="form-label">New Password <span style="color:var(--muted);font-weight:400;">(optional)</span></label>
<input type="password" class="form-control" name="new_password" placeholder="Leave blank to keep current"/>
<div class="pw-wrap">
<input type="password" class="form-control pw-input" id="new-password" name="new_password"
maxlength="128" placeholder="Leave blank to keep current"
autocomplete="new-password"/>
<button type="button" class="pw-toggle" id="new-password-toggle"
title="Show / hide password" tabindex="-1">
<i class="bi bi-eye"></i>
</button>
</div>
<div style="font-size:11px;color:var(--muted);margin-top:5px;">Leave both fields blank to keep the current password.</div>
</div>
<div class="col-md-6">
<label class="form-label">Confirm Password</label>
<div class="pw-wrap">
<input type="password" class="form-control pw-input" id="confirm-password" name="confirm_password"
maxlength="128" placeholder="Repeat new password"
autocomplete="new-password"/>
<button type="button" class="pw-toggle" id="confirm-password-toggle"
title="Show / hide password" tabindex="-1">
<i class="bi bi-eye"></i>
</button>
</div>
<div class="pw-match" id="pw-match-msg" aria-live="polite" aria-atomic="true"></div>
</div>
<div class="col-12">
<!-- Strength bar -->
<div class="pw-strength-wrap" id="pw-strength-wrap" style="display:none;">
<div class="pw-strength-track">
<div class="pw-strength-fill" id="pw-strength-bar"></div>
</div>
<div class="pw-strength-label" id="pw-strength-label"></div>
</div>
<!-- Rule checklist — mirrors server-side validate_password() rules -->
<div class="pw-rules" id="pw-rules-box" style="display:none;" aria-live="polite" aria-atomic="true">
<div class="pw-rule unmet" id="pw-rule-len">
<span class="ri"><i class="bi bi-circle"></i></span>
<span>8+ characters</span>
</div>
<div class="pw-rule unmet" id="pw-rule-upper">
<span class="ri"><i class="bi bi-circle"></i></span>
<span>Uppercase letter</span>
</div>
<div class="pw-rule unmet" id="pw-rule-digit">
<span class="ri"><i class="bi bi-circle"></i></span>
<span>Number</span>
</div>
<div class="pw-rule unmet" id="pw-rule-special">
<span class="ri"><i class="bi bi-circle"></i></span>
<span>Special character</span>
</div>
</div>
</div>
</div>
<div class="d-flex gap-2 mt-4">
@@ -57,3 +112,33 @@
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/password-strength.js') }}"></script>
<script>
const pwStrength = PasswordStrength.init({
password : 'new-password',
confirm : 'confirm-password',
strengthBar : 'pw-strength-bar',
strengthLabel: 'pw-strength-label',
strengthWrap : 'pw-strength-wrap',
rulesWrap : 'pw-rules-box',
rules: { len: 'pw-rule-len', upper: 'pw-rule-upper', digit: 'pw-rule-digit', special: 'pw-rule-special' },
matchMsg : 'pw-match-msg',
toggles: [
{ input: 'new-password', button: 'new-password-toggle' },
{ input: 'confirm-password', button: 'confirm-password-toggle' },
],
});
// ── Block submit on a real mismatch (server re-validates regardless) ──────────
document.querySelector('form').addEventListener('submit', function (e) {
const pw = document.getElementById('new-password').value;
const pw2 = document.getElementById('confirm-password').value;
if (pw !== pw2) {
e.preventDefault();
pwStrength.renderMatch();
}
});
</script>
{% endblock %}
+2 -1
View File
@@ -211,7 +211,8 @@
</span>
</label>
<input type="password" class="form-control" name="email_ingestion_password"
value="{{ email_settings.password }}" placeholder="Leave blank to keep current"/>
autocomplete="new-password"
placeholder="{{ 'Leave blank to keep current (already set)' if email_settings.has_password else 'Not set' }}"/>
</div>
<div class="col-md-4">
<label class="form-label">Watch Folder</label>
-3
View File
@@ -56,9 +56,6 @@
<i class="bi bi-collection me-2"></i>Tickets
<span style="font-size:12px;color:var(--muted);font-family:'Space Mono',monospace;">({{ tickets.total }})</span>
</div>
<a href="{{ url_for('tickets.create_ticket_behalf') }}" class="btn btn-primary btn-sm">
<i class="bi bi-person-plus me-1"></i>File on Behalf
</a>
</div>
<div class="card-body p-0">
{% if tickets.items %}
+41 -106
View File
@@ -2,6 +2,10 @@
{% block title %}My Profile{% endblock %}
{% block page_title %}My Profile{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/password-strength.css') }}"/>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-7">
@@ -73,51 +77,42 @@
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label">New Password</label>
<!-- Show/hide toggle wrapper -->
<div style="position:relative;">
<input type="password" class="form-control" name="new_password"
id="prof-pw" placeholder="Min. 8 characters"
autocomplete="new-password"
oninput="profPwInput()" style="padding-right:40px;"/>
<button type="button" id="prof-pw-toggle"
onclick="profToggle('prof-pw','prof-pw-toggle')"
tabindex="-1"
style="position:absolute;right:10px;top:50%;transform:translateY(-50%);
background:none;border:none;color:var(--muted);cursor:pointer;font-size:15px;">
<div class="pw-wrap">
<input type="password" class="form-control pw-input" name="new_password"
id="prof-pw" maxlength="128" placeholder="Min. 8 characters"
autocomplete="new-password"/>
<button type="button" class="pw-toggle" id="prof-pw-toggle"
title="Show / hide password" tabindex="-1">
<i class="bi bi-eye"></i>
</button>
</div>
<!-- Strength bar (hidden until typing starts) -->
<div id="prof-strength-wrap" style="display:none;margin-top:6px;">
<div style="height:4px;background:var(--border);border-radius:2px;overflow:hidden;margin-bottom:5px;">
<div id="prof-strength-bar" style="height:100%;border-radius:2px;width:0%;transition:width .3s,background .3s;"></div>
<div class="pw-strength-wrap" id="prof-strength-wrap" style="display:none;">
<div class="pw-strength-track">
<div class="pw-strength-fill" id="prof-strength-bar"></div>
</div>
<div id="prof-strength-label" style="font-size:11px;font-weight:600;font-family:'Space Mono',monospace;display:flex;align-items:center;gap:5px;"></div>
<div class="pw-strength-label" id="prof-strength-label"></div>
</div>
<!-- Rule checklist -->
<div id="prof-rules" style="display:none;margin-top:7px;background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:9px 12px;display:grid;grid-template-columns:1fr 1fr;gap:3px 10px;">
<div class="prof-rule" id="prof-rule-len" style="display:flex;align-items:center;gap:5px;font-size:11.5px;color:var(--muted);"><span class="prof-ri"><i class="bi bi-circle"></i></span> 8+ characters</div>
<div class="prof-rule" id="prof-rule-upper" style="display:flex;align-items:center;gap:5px;font-size:11.5px;color:var(--muted);"><span class="prof-ri"><i class="bi bi-circle"></i></span> Uppercase letter</div>
<div class="prof-rule" id="prof-rule-digit" style="display:flex;align-items:center;gap:5px;font-size:11.5px;color:var(--muted);"><span class="prof-ri"><i class="bi bi-circle"></i></span> Number</div>
<div class="prof-rule" id="prof-rule-special"style="display:flex;align-items:center;gap:5px;font-size:11.5px;color:var(--muted);"><span class="prof-ri"><i class="bi bi-circle"></i></span> Special character</div>
<div class="pw-rules" id="prof-rules" style="display:none;" aria-live="polite" aria-atomic="true">
<div class="pw-rule unmet" id="prof-rule-len"><span class="ri"><i class="bi bi-circle"></i></span> 8+ characters</div>
<div class="pw-rule unmet" id="prof-rule-upper"><span class="ri"><i class="bi bi-circle"></i></span> Uppercase letter</div>
<div class="pw-rule unmet" id="prof-rule-digit"><span class="ri"><i class="bi bi-circle"></i></span> Number</div>
<div class="pw-rule unmet" id="prof-rule-special"><span class="ri"><i class="bi bi-circle"></i></span> Special character</div>
</div>
</div>
<div class="col-md-6">
<label class="form-label">Confirm Password</label>
<div style="position:relative;">
<input type="password" class="form-control" name="confirm_password"
id="prof-pw2" placeholder="Repeat password"
autocomplete="new-password"
oninput="profConfirmInput()" style="padding-right:40px;"/>
<button type="button" id="prof-pw2-toggle"
onclick="profToggle('prof-pw2','prof-pw2-toggle')"
tabindex="-1"
style="position:absolute;right:10px;top:50%;transform:translateY(-50%);
background:none;border:none;color:var(--muted);cursor:pointer;font-size:15px;">
<div class="pw-wrap">
<input type="password" class="form-control pw-input" name="confirm_password"
id="prof-pw2" maxlength="128" placeholder="Repeat password"
autocomplete="new-password"/>
<button type="button" class="pw-toggle" id="prof-pw2-toggle"
title="Show / hide password" tabindex="-1">
<i class="bi bi-eye"></i>
</button>
</div>
<div id="prof-match-msg" style="font-size:11.5px;margin-top:6px;font-family:'Space Mono',monospace;font-weight:600;min-height:18px;"></div>
<div class="pw-match" id="prof-match-msg" aria-live="polite" aria-atomic="true"></div>
</div>
</div>
@@ -132,82 +127,22 @@
</div>
{% block scripts %}
<script src="{{ url_for('static', filename='js/password-strength.js') }}"></script>
<script>
// ── Profile page password strength — mirrors register.html rules exactly ──────
const PROF_RULES = {
len: pw => pw.length >= 8,
upper: pw => /[A-Z]/.test(pw),
digit: pw => /\d/.test(pw),
special: pw => /[!@#$%^&*()\-_=+\[\]{};:'",.<>?/\|`~]/.test(pw),
};
const PROF_LEVELS = [
{ label:'Very Weak', color:'#ef4444', pct:12 },
{ label:'Weak', color:'#f97316', pct:30 },
{ label:'Fair', color:'#eab308', pct:52 },
{ label:'Good', color:'#22c55e', pct:76 },
{ label:'Strong', color:'#059669', pct:100 },
];
function profScore(pw) {
return [PROF_RULES.len,PROF_RULES.upper,PROF_RULES.digit,PROF_RULES.special]
.filter(r => r(pw)).length + (pw.length >= 16 ? 1 : 0);
}
function profSetRule(id, met) {
const el = document.getElementById('prof-rule-' + id);
if (!el) return;
el.style.color = met ? 'var(--success)' : 'var(--muted)';
el.querySelector('.prof-ri').innerHTML = met
? '<i class="bi bi-check-circle-fill" style="color:var(--success);"></i>'
: '<i class="bi bi-circle"></i>';
}
function profPwInput() {
const pw = document.getElementById('prof-pw').value;
const wrap = document.getElementById('prof-strength-wrap');
const rules = document.getElementById('prof-rules');
const bar = document.getElementById('prof-strength-bar');
const lbl = document.getElementById('prof-strength-label');
if (!pw) {
wrap.style.display = 'none'; rules.style.display = 'none';
document.getElementById('prof-pw').classList.remove('is-valid','is-invalid');
profConfirmInput(); return;
}
wrap.style.display = 'block'; rules.style.display = 'grid';
profSetRule('len', PROF_RULES.len(pw));
profSetRule('upper', PROF_RULES.upper(pw));
profSetRule('digit', PROF_RULES.digit(pw));
profSetRule('special', PROF_RULES.special(pw));
const score = profScore(pw);
const lvl = PROF_LEVELS[Math.max(0, score - 1)] || PROF_LEVELS[0];
bar.style.width = lvl.pct + '%';
bar.style.background = lvl.color;
lbl.style.color = lvl.color;
const icon = score >= 4 ? 'shield-fill' : score >= 2 ? 'shield-half' : 'shield';
lbl.innerHTML = `<i class="bi bi-${icon}"></i> ${lvl.label}`;
const allMet = Object.values(PROF_RULES).every(r => r(pw));
document.getElementById('prof-pw').classList.toggle('is-valid', allMet);
document.getElementById('prof-pw').classList.toggle('is-invalid', !allMet);
profConfirmInput();
}
function profConfirmInput() {
const pw = document.getElementById('prof-pw').value;
const pw2 = document.getElementById('prof-pw2').value;
const msg = document.getElementById('prof-match-msg');
const el2 = document.getElementById('prof-pw2');
if (!pw2) { msg.innerHTML=''; el2.classList.remove('is-valid','is-invalid'); return; }
const ok = pw === pw2;
msg.innerHTML = ok
? '<span style="color:var(--success);"><i class="bi bi-check-circle-fill me-1"></i>Passwords match</span>'
: '<span style="color:var(--danger);"><i class="bi bi-x-circle-fill me-1"></i>Passwords do not match</span>';
el2.classList.toggle('is-valid', ok);
el2.classList.toggle('is-invalid', !ok);
}
function profToggle(inputId, btnId) {
const inp = document.getElementById(inputId);
const btn = document.getElementById(btnId);
const isPassword = inp.type === 'password';
inp.type = isPassword ? 'text' : 'password';
btn.querySelector('i').className = isPassword ? 'bi bi-eye-slash' : 'bi bi-eye';
}
PasswordStrength.init({
password : 'prof-pw',
confirm : 'prof-pw2',
strengthBar : 'prof-strength-bar',
strengthLabel: 'prof-strength-label',
strengthWrap : 'prof-strength-wrap',
rulesWrap : 'prof-rules',
rules: { len: 'prof-rule-len', upper: 'prof-rule-upper', digit: 'prof-rule-digit', special: 'prof-rule-special' },
matchMsg : 'prof-match-msg',
toggles: [
{ input: 'prof-pw', button: 'prof-pw-toggle' },
{ input: 'prof-pw2', button: 'prof-pw2-toggle' },
],
});
</script>
{% endblock %}
{% endblock %}
+38 -190
View File
@@ -6,6 +6,7 @@
<style>:root { --accent: {{ branding.primary_color }}; --accent-h: {{ branding.primary_color }}; }</style>
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&display=swap" rel="stylesheet"/>
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet"/>
<link rel="stylesheet" href="{{ url_for('static', filename='css/password-strength.css') }}"/>
<style>
:root{
--bg:#f0f4f8;--surface:#ffffff;--border:#e2e8f0;--border2:#cbd5e1;
@@ -31,61 +32,10 @@
input.valid{border-color:var(--success);box-shadow:0 0 0 3px rgba(5,150,105,.08);}
input.invalid{border-color:var(--danger);box-shadow:0 0 0 3px rgba(220,38,38,.08);}
/* ── Password input wrapper (for show/hide toggle) ── */
.pw-wrap{position:relative;}
.pw-wrap input{padding-right:42px;}
.pw-toggle{
position:absolute;right:12px;top:50%;transform:translateY(-50%);
background:none;border:none;color:var(--muted);cursor:pointer;
font-size:16px;padding:2px;line-height:1;
transition:color .15s;
}
.pw-toggle:hover{color:var(--accent);}
/* ── Strength bar ── */
.strength-wrap{margin-top:8px;}
.strength-bar-track{
height:4px;background:var(--border);border-radius:2px;
overflow:hidden;margin-bottom:6px;
}
.strength-bar-fill{
height:100%;border-radius:2px;width:0%;
transition:width .3s ease,background .3s ease;
}
.strength-label{
font-size:11px;font-weight:600;letter-spacing:.3px;
font-family:'Space Mono',monospace;
transition:color .3s;
display:flex;align-items:center;gap:5px;
}
/* ── Rule checklist ── */
.rules{
margin-top:8px;display:grid;grid-template-columns:1fr 1fr;gap:3px 12px;
background:var(--bg);border:1px solid var(--border);border-radius:8px;
padding:10px 12px;
}
.rule{
display:flex;align-items:center;gap:5px;
font-size:11.5px;color:var(--muted);
transition:color .2s;
}
.rule .ri{
font-size:13px;width:14px;text-align:center;
transition:color .2s, transform .15s;
}
.rule.met{color:var(--success);}
.rule.met .ri{color:var(--success);transform:scale(1.1);}
.rule.unmet .ri{color:var(--border2);}
/* ── Confirm match indicator ── */
.match-msg{
font-size:11.5px;margin-top:6px;display:flex;align-items:center;gap:5px;
font-family:'Space Mono',monospace;font-weight:600;letter-spacing:.2px;
min-height:18px;transition:color .2s;
}
.match-msg.ok{color:var(--success);}
.match-msg.fail{color:var(--danger);}
/* Register page renders the strength label/match message in the
monospace brand font; the shared stylesheet leaves font-family
unset so it inherits from the page. */
.pw-strength-label, .pw-match{font-family:'Space Mono',monospace;}
/* ── Submit ── */
.btn{width:100%;background:var(--accent);border:none;color:#fff;border-radius:9px;padding:12px;font-size:14px;font-weight:600;cursor:pointer;transition:background .15s;font-family:inherit;margin-top:4px;box-shadow:0 2px 6px rgba(37,99,235,.3);}
@@ -152,11 +102,10 @@
<label>Password *</label>
<div class="pw-wrap">
<input type="password" name="password" id="pw" required
maxlength="128"
placeholder="Min. 8 characters"
autocomplete="new-password"
oninput="onPasswordInput()"/>
autocomplete="new-password"/>
<button type="button" class="pw-toggle" id="pw-toggle"
onclick="toggleVis('pw','pw-toggle')"
title="Show / hide password"
tabindex="-1">
<i class="bi bi-eye"></i>
@@ -164,28 +113,28 @@
</div>
<!-- Strength bar -->
<div class="strength-wrap" id="strength-wrap" style="display:none;">
<div class="strength-bar-track">
<div class="strength-bar-fill" id="strength-bar"></div>
<div class="pw-strength-wrap" id="strength-wrap" style="display:none;">
<div class="pw-strength-track">
<div class="pw-strength-fill" id="strength-bar"></div>
</div>
<div class="strength-label" id="strength-label"></div>
<div class="pw-strength-label" id="strength-label"></div>
</div>
<!-- Rule checklist — mirrors server-side validate_password() rules -->
<div class="rules" id="rules-box" style="display:none;">
<div class="rule unmet" id="rule-len">
<div class="pw-rules" id="rules-box" style="display:none;" aria-live="polite" aria-atomic="true">
<div class="pw-rule unmet" id="rule-len">
<span class="ri"><i class="bi bi-circle"></i></span>
<span>8+ characters</span>
</div>
<div class="rule unmet" id="rule-upper">
<div class="pw-rule unmet" id="rule-upper">
<span class="ri"><i class="bi bi-circle"></i></span>
<span>Uppercase letter</span>
</div>
<div class="rule unmet" id="rule-digit">
<div class="pw-rule unmet" id="rule-digit">
<span class="ri"><i class="bi bi-circle"></i></span>
<span>Number</span>
</div>
<div class="rule unmet" id="rule-special">
<div class="pw-rule unmet" id="rule-special">
<span class="ri"><i class="bi bi-circle"></i></span>
<span>Special character</span>
</div>
@@ -197,17 +146,16 @@
<label>Confirm Password *</label>
<div class="pw-wrap">
<input type="password" name="confirm_password" id="pw2" required
maxlength="128"
placeholder="Repeat password"
autocomplete="new-password"
oninput="onConfirmInput()"/>
autocomplete="new-password"/>
<button type="button" class="pw-toggle" id="pw2-toggle"
onclick="toggleVis('pw2','pw2-toggle')"
title="Show / hide password"
tabindex="-1">
<i class="bi bi-eye"></i>
</button>
</div>
<div class="match-msg" id="match-msg"></div>
<div class="pw-match" id="match-msg" aria-live="polite" aria-atomic="true"></div>
</div>
<button type="submit" class="btn" id="submit-btn">
@@ -217,133 +165,33 @@
<div class="links">Already have an account? <a href="{{ url_for('auth.login') }}">Sign in</a></div>
</div>
<script src="{{ url_for('static', filename='js/password-strength.js') }}"></script>
<script>
// ── Mirrors server-side validate_password() rules in validation_service.py ──
// Any change to the server rules must be reflected here too.
const RULES = {
len : pw => pw.length >= 8,
upper : pw => /[A-Z]/.test(pw),
digit : pw => /\d/.test(pw),
special: pw => /[!@#$%^&*()\-_=+\[\]{};:'",.<>?/\\|`~]/.test(pw),
};
// Strength scoring: each rule = 1 point; bonus point for length >= 16
function scorePassword(pw) {
let score = 0;
if (RULES.len(pw)) score++;
if (RULES.upper(pw)) score++;
if (RULES.digit(pw)) score++;
if (RULES.special(pw)) score++;
if (pw.length >= 16) score++; // bonus for extra length
return score; // 05
}
const LEVELS = [
{ label: 'Very Weak', color: '#ef4444', pct: 12 },
{ label: 'Weak', color: '#f97316', pct: 30 },
{ label: 'Fair', color: '#eab308', pct: 52 },
{ label: 'Good', color: '#22c55e', pct: 76 },
{ label: 'Strong', color: '#059669', pct: 100 },
];
function setRule(id, met) {
const el = document.getElementById('rule-' + id);
if (!el) return;
el.className = 'rule ' + (met ? 'met' : 'unmet');
el.querySelector('.ri').innerHTML = met
? '<i class="bi bi-check-circle-fill"></i>'
: '<i class="bi bi-circle"></i>';
}
function onPasswordInput() {
const pw = document.getElementById('pw').value;
const wrap = document.getElementById('strength-wrap');
const rules = document.getElementById('rules-box');
const bar = document.getElementById('strength-bar');
const lbl = document.getElementById('strength-label');
if (pw.length === 0) {
wrap.style.display = 'none';
rules.style.display = 'none';
document.getElementById('pw').classList.remove('valid','invalid');
updateSubmitState();
onConfirmInput();
return;
}
wrap.style.display = 'block';
rules.style.display = 'grid';
// Update rule checklist
setRule('len', RULES.len(pw));
setRule('upper', RULES.upper(pw));
setRule('digit', RULES.digit(pw));
setRule('special', RULES.special(pw));
// Update strength bar
const score = scorePassword(pw);
const lvl = LEVELS[Math.max(0, score - 1)] || LEVELS[0];
bar.style.width = lvl.pct + '%';
bar.style.background = lvl.color;
lbl.style.color = lvl.color;
lbl.innerHTML = `<i class="bi bi-shield${score >= 4 ? '-fill' : score >= 2 ? '-half' : ''}"></i> ${lvl.label}`;
// Border feedback on password field
const allMet = RULES.len(pw) && RULES.upper(pw) && RULES.digit(pw) && RULES.special(pw);
document.getElementById('pw').classList.toggle('valid', allMet);
document.getElementById('pw').classList.toggle('invalid', !allMet);
onConfirmInput();
updateSubmitState();
}
function onConfirmInput() {
const pw = document.getElementById('pw').value;
const pw2 = document.getElementById('pw2').value;
const msg = document.getElementById('match-msg');
const el2 = document.getElementById('pw2');
if (pw2.length === 0) {
msg.textContent = '';
msg.className = 'match-msg';
el2.classList.remove('valid','invalid');
updateSubmitState();
return;
}
if (pw === pw2) {
msg.innerHTML = '<i class="bi bi-check-circle-fill"></i> Passwords match';
msg.className = 'match-msg ok';
el2.classList.add('valid');
el2.classList.remove('invalid');
} else {
msg.innerHTML = '<i class="bi bi-x-circle-fill"></i> Passwords do not match';
msg.className = 'match-msg fail';
el2.classList.add('invalid');
el2.classList.remove('valid');
}
updateSubmitState();
}
const pwStrength = PasswordStrength.init({
password : 'pw',
confirm : 'pw2',
strengthBar : 'strength-bar',
strengthLabel: 'strength-label',
strengthWrap : 'strength-wrap',
rulesWrap : 'rules-box',
rules: { len: 'rule-len', upper: 'rule-upper', digit: 'rule-digit', special: 'rule-special' },
matchMsg : 'match-msg',
toggles: [
{ input: 'pw', button: 'pw-toggle' },
{ input: 'pw2', button: 'pw2-toggle' },
],
onChange: updateSubmitState,
});
function updateSubmitState() {
const pw = document.getElementById('pw').value;
const pw2 = document.getElementById('pw2').value;
const btn = document.getElementById('submit-btn');
const allMet = RULES.len(pw) && RULES.upper(pw) && RULES.digit(pw) && RULES.special(pw);
const match = pw === pw2 && pw2.length > 0;
// Disable only when user has started typing in either field and criteria
// are not met — never disable before they've interacted with the fields.
// Disable only when the user has started typing in either field and
// criteria are not met — never disable before they've interacted.
const hasStarted = pw.length > 0 || pw2.length > 0;
btn.disabled = hasStarted && !(allMet && match);
}
function toggleVis(inputId, btnId) {
const inp = document.getElementById(inputId);
const btn = document.getElementById(btnId);
const isPassword = inp.type === 'password';
inp.type = isPassword ? 'text' : 'password';
btn.querySelector('i').className = isPassword ? 'bi bi-eye-slash' : 'bi bi-eye';
btn.title = isPassword ? 'Hide password' : 'Show password';
btn.disabled = hasStarted && !(PasswordStrength.allRulesMet(pw) && match);
}
</script>
</body>
+79 -32
View File
@@ -6,8 +6,13 @@
<style>:root { --accent: {{ branding.primary_color }}; --accent-h: {{ branding.primary_color }}; }</style>
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&display=swap" rel="stylesheet"/>
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet"/>
<link rel="stylesheet" href="{{ url_for('static', filename='css/password-strength.css') }}"/>
<style>
:root{--bg:#f0f4f8;--border:#e2e8f0;--accent:#2563eb;--accent-h:#1d4ed8;--text:#0f172a;--text2:#334155;--muted:#64748b;--danger:#dc2626;--danger-bg:#fef2f2;}
:root{
--bg:#f0f4f8;--border:#e2e8f0;--border2:#cbd5e1;
--accent:#2563eb;--accent-h:#1d4ed8;--text:#0f172a;--text2:#334155;--muted:#64748b;
--success:#059669;--danger:#dc2626;--danger-bg:#fef2f2;
}
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:'DM Sans',sans-serif;background:var(--bg);min-height:100vh;display:flex;align-items:center;justify-content:center;}
.bg-grid{position:fixed;inset:0;background-image:linear-gradient(var(--border) 1px,transparent 1px),linear-gradient(90deg,var(--border) 1px,transparent 1px);background-size:48px 48px;opacity:.6;pointer-events:none;}
@@ -23,13 +28,11 @@
input::placeholder{color:#94a3b8;}
.btn{width:100%;background:var(--accent);border:none;color:#fff;border-radius:9px;padding:12px;font-size:14px;font-weight:600;cursor:pointer;transition:background .15s;font-family:inherit;box-shadow:0 2px 6px rgba(37,99,235,.3);}
.btn:hover{background:var(--accent-h);}
.btn:disabled{background:#93c5fd;cursor:not-allowed;box-shadow:none;}
.links{text-align:center;margin-top:18px;font-size:13px;color:var(--muted);}
.links a{color:var(--accent);}
.alert{border-radius:8px;padding:11px 14px;font-size:13px;margin-bottom:18px;}
.alert-danger{background:var(--danger-bg);color:var(--danger);border:1px solid #fecaca;}
/* strength meter */
#pw-strength-bar{height:4px;border-radius:2px;transition:width .3s,background .3s;background:#e2e8f0;width:0;}
#pw-strength-text{font-size:11px;color:var(--muted);margin-top:4px;}
</style>
</head>
<body>
@@ -53,46 +56,90 @@
{% endfor %}
{% endwith %}
<form method="POST">
<form method="POST" id="reset-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label>New Password</label>
<input type="password" name="password" id="pw-new" required placeholder="Min. 8 characters" autofocus
oninput="checkStrength(this.value)"/>
<div style="margin-top:6px;background:#e2e8f0;border-radius:2px;height:4px;">
<div id="pw-strength-bar"></div>
<div class="pw-wrap">
<input type="password" name="password" id="pw-new" required maxlength="128"
placeholder="Min. 8 characters" autofocus
autocomplete="new-password"/>
<button type="button" class="pw-toggle" id="pw-new-toggle"
title="Show / hide password" tabindex="-1">
<i class="bi bi-eye"></i>
</button>
</div>
<!-- Strength bar -->
<div class="pw-strength-wrap" id="strength-wrap" style="display:none;">
<div class="pw-strength-track">
<div class="pw-strength-fill" id="strength-bar"></div>
</div>
<div class="pw-strength-label" id="strength-label"></div>
</div>
<!-- Rule checklist — mirrors server-side validate_password() rules -->
<div class="pw-rules" id="rules-box" style="display:none;" aria-live="polite" aria-atomic="true">
<div class="pw-rule unmet" id="rule-len">
<span class="ri"><i class="bi bi-circle"></i></span>
<span>8+ characters</span>
</div>
<div class="pw-rule unmet" id="rule-upper">
<span class="ri"><i class="bi bi-circle"></i></span>
<span>Uppercase letter</span>
</div>
<div class="pw-rule unmet" id="rule-digit">
<span class="ri"><i class="bi bi-circle"></i></span>
<span>Number</span>
</div>
<div class="pw-rule unmet" id="rule-special">
<span class="ri"><i class="bi bi-circle"></i></span>
<span>Special character</span>
</div>
</div>
<div id="pw-strength-text"></div>
</div>
<div class="form-group">
<label>Confirm Password</label>
<input type="password" name="confirm_password" required placeholder="Repeat password"/>
<div class="pw-wrap">
<input type="password" name="confirm_password" id="pw-confirm" required maxlength="128"
placeholder="Repeat password"
autocomplete="new-password"/>
<button type="button" class="pw-toggle" id="pw-confirm-toggle"
title="Show / hide password" tabindex="-1">
<i class="bi bi-eye"></i>
</button>
</div>
<button type="submit" class="btn"><i class="bi bi-lock me-2"></i>Update Password</button>
<div class="pw-match" id="match-msg" aria-live="polite" aria-atomic="true"></div>
</div>
<button type="submit" class="btn" id="submit-btn"><i class="bi bi-lock me-2"></i>Update Password</button>
</form>
<div class="links"><a href="{{ url_for('auth.login') }}">← Back to Sign In</a></div>
</div>
<script src="{{ url_for('static', filename='js/password-strength.js') }}"></script>
<script>
function checkStrength(pw) {
let score = 0;
if (pw.length >= 8) score++;
if (/[A-Z]/.test(pw)) score++;
if (/[0-9]/.test(pw)) score++;
if (/[^A-Za-z0-9]/.test(pw)) score++;
const bar = document.getElementById('pw-strength-bar');
const text = document.getElementById('pw-strength-text');
const levels = [
{ w: '0%', bg: '#e2e8f0', label: '' },
{ w: '25%', bg: '#dc2626', label: 'Weak' },
{ w: '50%', bg: '#d97706', label: 'Fair' },
{ w: '75%', bg: '#2563eb', label: 'Good' },
{ w: '100%', bg: '#059669', label: 'Strong' },
];
const lvl = levels[score] || levels[0];
bar.style.width = lvl.w;
bar.style.background = lvl.bg;
text.textContent = lvl.label;
text.style.color = lvl.bg;
const pwStrength = PasswordStrength.init({
password : 'pw-new',
confirm : 'pw-confirm',
strengthBar : 'strength-bar',
strengthLabel: 'strength-label',
strengthWrap : 'strength-wrap',
rulesWrap : 'rules-box',
rules: { len: 'rule-len', upper: 'rule-upper', digit: 'rule-digit', special: 'rule-special' },
matchMsg : 'match-msg',
toggles: [
{ input: 'pw-new', button: 'pw-new-toggle' },
{ input: 'pw-confirm', button: 'pw-confirm-toggle' },
],
onChange: updateSubmitState,
});
function updateSubmitState() {
const pw = document.getElementById('pw-new').value;
const pw2 = document.getElementById('pw-confirm').value;
const btn = document.getElementById('submit-btn');
const match = pw === pw2 && pw2.length > 0;
const hasStarted = pw.length > 0 || pw2.length > 0;
btn.disabled = hasStarted && !(PasswordStrength.allRulesMet(pw) && match);
}
</script>
</body>
+1 -1
View File
@@ -141,7 +141,7 @@
<button type="submit" class="btn btn-primary" id="submit-btn">
<i class="bi bi-send me-2"></i>Submit Ticket
</button>
<a href="{{ url_for('admin.all_tickets') }}" class="btn btn-secondary">Cancel</a>
<a href="{{ url_for('tickets.ticket_list') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
+7
View File
@@ -53,10 +53,17 @@
Tickets
<span style="font-size:12px;color:var(--muted);font-family:'Space Mono',monospace;">({{ tickets.total }})</span>
</span>
<div class="d-flex gap-2">
{% if current_user.is_it_staff %}
<a href="{{ url_for('tickets.create_ticket_behalf') }}" class="btn btn-primary btn-sm">
<i class="bi bi-person-plus me-1"></i>File on Behalf
</a>
{% endif %}
<a href="{{ url_for('tickets.create_ticket') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>New Ticket
</a>
</div>
</div>
<div class="card-body p-0">
{% if tickets.items %}
<div class="table-responsive">
@@ -0,0 +1,62 @@
"""Add tickets.sla_breach_notified column; retire sla_notified_tickets setting
Revision ID: 006_add_sla_breach_notified
Revises: 005_add_watchers_and_time_entries
Create Date: 2026-07-02
Rationale
---------
SLA breach-notification suppression was previously tracked as a
comma-separated list of ticket IDs in a single SystemSetting row
(sla_notified_tickets). That works but doesn't scale cleanly and requires
parsing a string on every 30-minute scheduler run. This migration replaces
it with a plain boolean column on the ticket itself the natural place for
a per-ticket flag and backfills it from the existing SystemSetting value
so already-notified tickets don't get re-notified after the upgrade.
Apply
-----
flask db upgrade
Rollback
--------
flask db downgrade
(Note: the backfilled sla_breach_notified flags are not restored back
into a SystemSetting row on downgrade if you roll back and then
upgrade again, already-breached tickets will re-notify once.)
"""
from alembic import op
import sqlalchemy as sa
revision = '006_add_sla_breach_notified'
down_revision = '005_add_watchers_and_time_entries'
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
'tickets',
sa.Column('sla_breach_notified', sa.Boolean, nullable=False, server_default='0'),
)
# Backfill from the old comma-separated SystemSetting, if present.
conn = op.get_bind()
row = conn.execute(
sa.text("SELECT value FROM system_settings WHERE `key` = 'sla_notified_tickets'")
).fetchone()
if row and row[0]:
ticket_ids = [int(x) for x in row[0].split(',') if x.strip().isdigit()]
if ticket_ids:
conn.execute(
sa.text(
"UPDATE tickets SET sla_breach_notified = 1 WHERE id IN :ids"
).bindparams(sa.bindparam('ids', expanding=True)),
{'ids': ticket_ids},
)
conn.execute(sa.text("DELETE FROM system_settings WHERE `key` = 'sla_notified_tickets'"))
def downgrade():
op.drop_column('tickets', 'sla_breach_notified')