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 │ └── versions/ # Alembic migration scripts
├── gunicorn.conf.py ├── gunicorn.conf.py
├── run.py # Entry point — calls eventlet.monkey_patch() first ├── 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 └── 004_widen_system_setting_value — system_settings.value VARCHAR(500) → TEXT
└── 005_add_watchers_and_time_entries — creates ticket_watchers └── 005_add_watchers_and_time_entries — creates ticket_watchers
and time_entries tables and time_entries tables
└── 006_add_sla_breach_notified — adds tickets.sla_breach_notified;
retires sla_notified_tickets setting
``` ```
**Rules:** **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)` | | `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` | | `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 | | `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) ## 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 ──────────────────────────────────────────────────── # ── Context processors ────────────────────────────────────────────────────
@app.context_processor @app.context_processor
def inject_globals(): def inject_globals():
from flask import has_request_context from flask import has_request_context, current_app
from flask_login import current_user from flask_login import current_user
from app.models import SystemSetting from app.models import SystemSetting
unread = 0 unread = 0
@@ -369,7 +369,7 @@ def _seed_settings():
('email_ingestion_host', '', 'IMAP server hostname (e.g. imap.gmail.com)'), ('email_ingestion_host', '', 'IMAP server hostname (e.g. imap.gmail.com)'),
('email_ingestion_port', '993', 'IMAP SSL port'), ('email_ingestion_port', '993', 'IMAP SSL port'),
('email_ingestion_user', '', 'Mailbox username / email address'), ('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_folder', 'INBOX', 'IMAP folder to watch for new mail'),
('email_ingestion_move_to', 'Processed', 'IMAP folder to move processed mail into'), ('email_ingestion_move_to', 'Processed', 'IMAP folder to move processed mail into'),
('email_ingestion_interval', '5', 'Poll interval in minutes'), ('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) closed_at = db.Column(db.DateTime)
due_date = 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) ai_generated = db.Column(db.Boolean, default=False)
internal_notes = db.Column(db.Text) internal_notes = db.Column(db.Text)
resolution_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', '') new_pw = request.form.get('new_password', '')
if new_pw: if new_pw:
confirm_pw = request.form.get('confirm_password', '') 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: if pw_error:
flash(pw_error, 'danger') flash(pw_error, 'danger')
return render_template('admin/edit_user.html', user=user, roles=_roles()) 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() att = KBAttachment.query.filter_by(id=att_id, article_id=article_id).first_or_404()
upload_dir = current_app.config['UPLOAD_FOLDER'] upload_dir = current_app.config['UPLOAD_FOLDER']
filepath = os.path.join(upload_dir, att.stored_name) filepath = os.path.join(upload_dir, att.stored_name)
if os.path.exists(filepath): att_id_val, filename = att.id, att.filename
os.remove(filepath)
log_action(current_user.id, 'kb_attachment_delete', 'kb_attachment', att.id, # DB row is the source of truth — delete and commit it first, then remove
f'article_id={article_id} filename={att.filename}') # the physical file. If the process dies mid-operation this leaves at
logger.info(f'[KB ATTACHMENT DELETE] att_id={att.id} article_id={article_id} by user_id={current_user.id}') # 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.delete(att)
db.session.commit() 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 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_move_to' : request.form.get('email_ingestion_move_to', 'Processed').strip(),
'email_ingestion_interval': request.form.get('email_ingestion_interval', '5').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() new_pw = request.form.get('email_ingestion_password', '').strip()
if new_pw: 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 = [] changes = []
for key, value in fields.items(): for key, value in fields.items():
@@ -1412,7 +1425,10 @@ def settings():
'host' : SystemSetting.get('email_ingestion_host', ''), 'host' : SystemSetting.get('email_ingestion_host', ''),
'port' : SystemSetting.get('email_ingestion_port', '993'), 'port' : SystemSetting.get('email_ingestion_port', '993'),
'user' : SystemSetting.get('email_ingestion_user', ''), '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'), 'folder' : SystemSetting.get('email_ingestion_folder', 'INBOX'),
'move_to' : SystemSetting.get('email_ingestion_move_to', 'Processed'), 'move_to' : SystemSetting.get('email_ingestion_move_to', 'Processed'),
'interval' : SystemSetting.get('email_ingestion_interval', '5'), '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 # Fall back to the stored password if the admin left the field blank
if not password: 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: if not host or not user or not password:
return jsonify(ok=False, message='Host, username, and password are required.') 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}') logger.info(f'[AUTH AVATAR UPLOAD] user_id={current_user.id} file={stored_name}')
if new_pw: 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: if pw_error:
flash(pw_error, 'danger') flash(pw_error, 'danger')
return render_template('auth/profile.html') return render_template('auth/profile.html')
@@ -236,12 +236,12 @@ def reset_password(token):
if request.method == 'POST': if request.method == 'POST':
password = request.form.get('password', '') password = request.form.get('password', '')
confirm = request.form.get('confirm_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: if pw_error:
flash(pw_error, 'danger') flash(pw_error, 'danger')
return render_template('auth/reset_password.html', token=token) return render_template('auth/reset_password.html', token=token)
user = token_row.user
user.set_password(password) user.set_password(password)
db.session.delete(token_row) # single-use — delete immediately db.session.delete(token_row) # single-use — delete immediately
log_action(user.id, 'password_reset', 'user', user.id) 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) db.session.add(att)
return 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 ──────────────────────────────────────────────────────────────── # ─── 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', '') host = _get_setting('email_ingestion_host', '')
port = int(_get_setting('email_ingestion_port', '993')) port = int(_get_setting('email_ingestion_port', '993'))
username = _get_setting('email_ingestion_user', '') 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') folder = _get_setting('email_ingestion_folder', 'INBOX')
move_to = _get_setting('email_ingestion_move_to', 'Processed') 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. 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 base64
@@ -34,10 +9,14 @@ from datetime import date, datetime
logger = logging.getLogger(__name__) 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----- 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-----""" -----END PUBLIC KEY-----"""
# ── Tier → feature set mapping ─────────────────────────────────────────────── # ── Tier → feature set mapping ───────────────────────────────────────────────
+19 -38
View File
@@ -16,11 +16,11 @@ Responsibilities
Notification suppression Notification suppression
------------------------ ------------------------
A dedicated SystemSetting key sla_notified_tickets stores a Ticket.sla_breach_notified is a per-ticket boolean flag, set once a breach
comma-separated list of ticket IDs that have already received a breach notification has been sent so the 30-minute scheduler run doesn't re-notify
notification. When a ticket is resolved or closed the ID is removed every time. When a ticket is resolved or closed the flag is cleared so
from the list so the suppression does not persist across re-opens the suppression does not persist across re-opens (edge case: ticket
(edge case: ticket re-opened after resolution unlikely but handled). re-opened after resolution unlikely but handled).
Design notes Design notes
------------ ------------
@@ -137,10 +137,10 @@ def check_sla_breaches(app):
"""Scheduled job: find overdue tickets and notify responsible parties. """Scheduled job: find overdue tickets and notify responsible parties.
Safe to call repeatedly already-notified tickets are suppressed via Safe to call repeatedly already-notified tickets are suppressed via
the sla_notified_tickets SystemSetting key. The suppression list is Ticket.sla_breach_notified. The flag is cleared for a ticket when it
cleared for a ticket when it transitions to resolved/closed (handled by transitions to resolved/closed (handled by the update_ticket route) or
the update_ticket route clearing it on status change) or when the ticket when the ticket is re-opened, ensuring fresh notifications if the issue
is re-opened, ensuring fresh notifications if the issue resurfaces. resurfaces.
""" """
with app.app_context(): with app.app_context():
from app.services.license_service import feature_enabled from app.services.license_service import feature_enabled
@@ -155,24 +155,18 @@ def check_sla_breaches(app):
def _run_sla_check(app): def _run_sla_check(app):
from app import db from app import db
from app.models import ( from app.models import Ticket, TicketStatus, User, UserRole, NotificationType
Ticket, TicketStatus, User, UserRole,
NotificationType, SystemSetting,
)
from app.services.notification_service import create_notification, send_email from app.services.notification_service import create_notification, send_email
from flask import render_template_string from flask import render_template_string
now = datetime.utcnow() now = datetime.utcnow()
# ── Load suppression list ────────────────────────────────────────────────── # ── Query overdue, not-yet-notified open tickets ───────────────────────────
raw = SystemSetting.get('sla_notified_tickets', '')
already_notified = set(int(x) for x in raw.split(',') if x.strip().isdigit())
# ── Query overdue open tickets ─────────────────────────────────────────────
overdue = Ticket.query.filter( overdue = Ticket.query.filter(
Ticket.status.in_([TicketStatus.OPEN, TicketStatus.IN_PROGRESS]), Ticket.status.in_([TicketStatus.OPEN, TicketStatus.IN_PROGRESS]),
Ticket.due_date.isnot(None), Ticket.due_date.isnot(None),
Ticket.due_date < now, Ticket.due_date < now,
Ticket.sla_breach_notified.is_(False),
).all() ).all()
if not overdue: if not overdue:
@@ -185,9 +179,6 @@ def _run_sla_check(app):
newly_notified = [] newly_notified = []
for ticket in overdue: for ticket in overdue:
if ticket.id in already_notified:
continue # already sent — skip
ticket_url = f'{base_url}/tickets/{ticket.id}' ticket_url = f'{base_url}/tickets/{ticket.id}'
overdue_mins = int((now - ticket.due_date).total_seconds() / 60) overdue_mins = int((now - ticket.due_date).total_seconds() / 60)
overdue_label = ( overdue_label = (
@@ -263,18 +254,11 @@ def _run_sla_check(app):
if it_dept_email: if it_dept_email:
send_email(subject, [it_dept_email], html) send_email(subject, [it_dept_email], html)
ticket.sla_breach_notified = True
newly_notified.append(ticket.id) newly_notified.append(ticket.id)
db.session.commit() db.session.commit()
# ── Update suppression list ────────────────────────────────────────────────
if newly_notified: 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( logger.info(
f'[SLA] Notified {len(newly_notified)} breach(es): ' f'[SLA] Notified {len(newly_notified)} breach(es): '
f'{[str(i) for i in newly_notified]}' f'{[str(i) for i in newly_notified]}'
@@ -282,17 +266,14 @@ def _run_sla_check(app):
def clear_sla_notification(ticket_id: int): 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 Call this when a ticket is resolved, closed, or re-opened so that
subsequent breaches (if the ticket re-opens) trigger fresh alerts. subsequent breaches (if the ticket re-opens) trigger fresh alerts.
Callers are responsible for committing after calling this function. Callers are responsible for committing after calling this function.
""" """
from app.models import SystemSetting from app import db
raw = SystemSetting.get('sla_notified_tickets', '') from app.models import Ticket
current = set(int(x) for x in raw.split(',') if x.strip().isdigit()) ticket = db.session.get(Ticket, ticket_id)
current.discard(ticket_id) if ticket:
SystemSetting.set( ticket.sla_breach_notified = False
'sla_notified_tickets',
','.join(str(i) for i in sorted(current)),
)
+18 -2
View File
@@ -13,7 +13,7 @@ logger = logging.getLogger(__name__)
# ─── Password Validation ────────────────────────────────────────────────────── # ─── 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. """Validate a new password and its confirmation field.
Returns an error message string if validation fails, or None if the 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 Rules
----- -----
- Password and confirmation must match. - 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 uppercase letter (A-Z).
- Must contain at least one digit (0-9). - Must contain at least one digit (0-9).
- Must contain at least one special character (!@#$%^&* etc.). - Must contain at least one special character (!@#$%^&* etc.).
- If current_hash is given, the new password must differ from it.
Parameters Parameters
---------- ----------
password : str the candidate password (plain text) password : str the candidate password (plain text)
confirm : str the confirmation field value 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 import re
if password != confirm: if password != confirm:
return 'Passwords do not match.' return 'Passwords do not match.'
if len(password) < 8: if len(password) < 8:
return 'Password must be at least 8 characters.' 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): if not re.search(r'[A-Z]', password):
return 'Password must contain at least one uppercase letter.' return 'Password must contain at least one uppercase letter.'
if not re.search(r'\d', password): if not re.search(r'\d', password):
return 'Password must contain at least one number.' return 'Password must contain at least one number.'
if not re.search(r'[!@#$%^&*()\-_=+\[\]{};:\'",.<>?/\\|`~]', password): if not re.search(r'[!@#$%^&*()\-_=+\[\]{};:\'",.<>?/\\|`~]', password):
return 'Password must contain at least one special character.' 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 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 title %}Create User{% endblock %}
{% block page_title %}User Management{% endblock %} {% block page_title %}User Management{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/password-strength.css') }}"/>
{% endblock %}
{% block content %} {% block content %}
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-lg-7"> <div class="col-lg-7">
@@ -97,36 +101,59 @@
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Password *</label> <label class="form-label">Password *</label>
<div style="position:relative;"> <div class="pw-wrap">
<input type="password" class="form-control" name="password" <input type="password" class="form-control pw-input" name="password"
id="pw-field" required minlength="8" id="pw-field" required minlength="8" maxlength="128"
placeholder="Min. 8 characters" placeholder="Min. 8 characters"
style="padding-right:44px;"/> autocomplete="new-password"/>
<button type="button" onclick="togglePw('pw-field','eye1')" <button type="button" class="pw-toggle" id="pw-field-toggle"
style="position:absolute;right:10px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--muted);cursor:pointer;padding:4px;"> title="Show / hide password" tabindex="-1">
<i class="bi bi-eye" id="eye1"></i> <i class="bi bi-eye"></i>
</button> </button>
</div> </div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Confirm Password *</label> <label class="form-label">Confirm Password *</label>
<div style="position:relative;"> <div class="pw-wrap">
<input type="password" class="form-control" name="confirm_password" <input type="password" class="form-control pw-input" name="confirm_password"
id="pw-field2" required minlength="8" id="pw-field2" required minlength="8" maxlength="128"
placeholder="Repeat password" placeholder="Repeat password"
style="padding-right:44px;"/> autocomplete="new-password"/>
<button type="button" onclick="togglePw('pw-field2','eye2')" <button type="button" class="pw-toggle" id="pw-field2-toggle"
style="position:absolute;right:10px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--muted);cursor:pointer;padding:4px;"> title="Show / hide password" tabindex="-1">
<i class="bi bi-eye" id="eye2"></i> <i class="bi bi-eye"></i>
</button> </button>
</div> </div>
<div class="pw-match" id="pw-match-msg" aria-live="polite" aria-atomic="true"></div>
</div> </div>
<div class="col-12"> <div class="col-12">
<!-- Password strength bar --> <!-- Strength bar -->
<div style="height:4px;background:var(--border);border-radius:2px;margin-top:4px;"> <div class="pw-strength-wrap" id="pw-strength-wrap" style="display:none;">
<div id="pw-strength-bar" style="height:100%;border-radius:2px;width:0%;transition:width .3s,background .3s;"></div> <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 id="pw-strength-label" style="font-size:11px;color:var(--muted);margin-top:4px;"></div>
</div> </div>
</div> </div>
@@ -146,71 +173,31 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ url_for('static', filename='js/password-strength.js') }}"></script>
<script> <script>
// ── Show/hide password ──────────────────────────────────────────────────────── const pwStrength = PasswordStrength.init({
function togglePw(fieldId, iconId) { password : 'pw-field',
const field = document.getElementById(fieldId); confirm : 'pw-field2',
const icon = document.getElementById(iconId); strengthBar : 'pw-strength-bar',
if (field.type === 'password') { strengthLabel: 'pw-strength-label',
field.type = 'text'; strengthWrap : 'pw-strength-wrap',
icon.className = 'bi bi-eye-slash'; rulesWrap : 'pw-rules-box',
} else { rules: { len: 'pw-rule-len', upper: 'pw-rule-upper', digit: 'pw-rule-digit', special: 'pw-rule-special' },
field.type = 'password'; matchMsg : 'pw-match-msg',
icon.className = 'bi bi-eye'; toggles: [
} { input: 'pw-field', button: 'pw-field-toggle' },
} { input: 'pw-field2', button: 'pw-field2-toggle' },
],
// ── 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;
}); });
// ── Client-side password match check before submit ──────────────────────────── // ── Block submit on a real mismatch (server re-validates regardless) ──────────
document.querySelector('form').addEventListener('submit', function (e) { document.querySelector('form').addEventListener('submit', function (e) {
const pw = document.getElementById('pw-field').value; const pw = document.getElementById('pw-field').value;
const pw2 = document.getElementById('pw-field2').value; const pw2 = document.getElementById('pw-field2').value;
if (pw !== pw2) { if (pw !== pw2) {
e.preventDefault(); e.preventDefault();
document.getElementById('pw-field2').style.borderColor = 'var(--danger)'; pwStrength.renderMatch();
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);
} }
}); });
document.getElementById('pw-field2').addEventListener('input', function () {
this.style.borderColor = '';
const msg = document.getElementById('pw-match-msg');
if (msg) msg.remove();
});
</script> </script>
{% endblock %} {% endblock %}
+86 -1
View File
@@ -2,6 +2,10 @@
{% block title %}Edit User{% endblock %} {% block title %}Edit User{% endblock %}
{% block page_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 %} {% block content %}
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-lg-6"> <div class="col-lg-6">
@@ -44,7 +48,58 @@
<div class="col-12"><hr style="border-color:var(--border);"/></div> <div class="col-12"><hr style="border-color:var(--border);"/></div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">New Password <span style="color:var(--muted);font-weight:400;">(optional)</span></label> <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> </div>
<div class="d-flex gap-2 mt-4"> <div class="d-flex gap-2 mt-4">
@@ -57,3 +112,33 @@
</div> </div>
</div> </div>
{% endblock %} {% 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> </span>
</label> </label>
<input type="password" class="form-control" name="email_ingestion_password" <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>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">Watch Folder</label> <label class="form-label">Watch Folder</label>
-3
View File
@@ -56,9 +56,6 @@
<i class="bi bi-collection me-2"></i>Tickets <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> <span style="font-size:12px;color:var(--muted);font-family:'Space Mono',monospace;">({{ tickets.total }})</span>
</div> </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>
<div class="card-body p-0"> <div class="card-body p-0">
{% if tickets.items %} {% if tickets.items %}
+41 -106
View File
@@ -2,6 +2,10 @@
{% block title %}My Profile{% endblock %} {% block title %}My Profile{% endblock %}
{% block page_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 %} {% block content %}
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-lg-7"> <div class="col-lg-7">
@@ -73,51 +77,42 @@
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">New Password</label> <label class="form-label">New Password</label>
<!-- Show/hide toggle wrapper --> <div class="pw-wrap">
<div style="position:relative;"> <input type="password" class="form-control pw-input" name="new_password"
<input type="password" class="form-control" name="new_password" id="prof-pw" maxlength="128" placeholder="Min. 8 characters"
id="prof-pw" placeholder="Min. 8 characters" autocomplete="new-password"/>
autocomplete="new-password" <button type="button" class="pw-toggle" id="prof-pw-toggle"
oninput="profPwInput()" style="padding-right:40px;"/> title="Show / hide password" tabindex="-1">
<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;">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</button> </button>
</div> </div>
<!-- Strength bar (hidden until typing starts) --> <!-- Strength bar (hidden until typing starts) -->
<div id="prof-strength-wrap" style="display:none;margin-top:6px;"> <div class="pw-strength-wrap" id="prof-strength-wrap" style="display:none;">
<div style="height:4px;background:var(--border);border-radius:2px;overflow:hidden;margin-bottom:5px;"> <div class="pw-strength-track">
<div id="prof-strength-bar" style="height:100%;border-radius:2px;width:0%;transition:width .3s,background .3s;"></div> <div class="pw-strength-fill" id="prof-strength-bar"></div>
</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> </div>
<!-- Rule checklist --> <!-- 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="pw-rules" id="prof-rules" style="display:none;" aria-live="polite" aria-atomic="true">
<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="pw-rule unmet" id="prof-rule-len"><span class="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="pw-rule unmet" id="prof-rule-upper"><span class="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="pw-rule unmet" id="prof-rule-digit"><span class="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-rule unmet" id="prof-rule-special"><span class="ri"><i class="bi bi-circle"></i></span> Special character</div>
</div> </div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Confirm Password</label> <label class="form-label">Confirm Password</label>
<div style="position:relative;"> <div class="pw-wrap">
<input type="password" class="form-control" name="confirm_password" <input type="password" class="form-control pw-input" name="confirm_password"
id="prof-pw2" placeholder="Repeat password" id="prof-pw2" maxlength="128" placeholder="Repeat password"
autocomplete="new-password" autocomplete="new-password"/>
oninput="profConfirmInput()" style="padding-right:40px;"/> <button type="button" class="pw-toggle" id="prof-pw2-toggle"
<button type="button" id="prof-pw2-toggle" title="Show / hide password" tabindex="-1">
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;">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</button> </button>
</div> </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>
</div> </div>
@@ -132,82 +127,22 @@
</div> </div>
{% block scripts %} {% block scripts %}
<script src="{{ url_for('static', filename='js/password-strength.js') }}"></script>
<script> <script>
// ── Profile page password strength — mirrors register.html rules exactly ────── PasswordStrength.init({
const PROF_RULES = { password : 'prof-pw',
len: pw => pw.length >= 8, confirm : 'prof-pw2',
upper: pw => /[A-Z]/.test(pw), strengthBar : 'prof-strength-bar',
digit: pw => /\d/.test(pw), strengthLabel: 'prof-strength-label',
special: pw => /[!@#$%^&*()\-_=+\[\]{};:'",.<>?/\|`~]/.test(pw), strengthWrap : 'prof-strength-wrap',
}; rulesWrap : 'prof-rules',
const PROF_LEVELS = [ rules: { len: 'prof-rule-len', upper: 'prof-rule-upper', digit: 'prof-rule-digit', special: 'prof-rule-special' },
{ label:'Very Weak', color:'#ef4444', pct:12 }, matchMsg : 'prof-match-msg',
{ label:'Weak', color:'#f97316', pct:30 }, toggles: [
{ label:'Fair', color:'#eab308', pct:52 }, { input: 'prof-pw', button: 'prof-pw-toggle' },
{ label:'Good', color:'#22c55e', pct:76 }, { input: 'prof-pw2', button: 'prof-pw2-toggle' },
{ 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';
}
</script> </script>
{% endblock %} {% endblock %}
{% endblock %} {% endblock %}
+38 -190
View File
@@ -6,6 +6,7 @@
<style>:root { --accent: {{ branding.primary_color }}; --accent-h: {{ branding.primary_color }}; }</style> <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://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 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> <style>
:root{ :root{
--bg:#f0f4f8;--surface:#ffffff;--border:#e2e8f0;--border2:#cbd5e1; --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.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);} input.invalid{border-color:var(--danger);box-shadow:0 0 0 3px rgba(220,38,38,.08);}
/* ── Password input wrapper (for show/hide toggle) ── */ /* Register page renders the strength label/match message in the
.pw-wrap{position:relative;} monospace brand font; the shared stylesheet leaves font-family
.pw-wrap input{padding-right:42px;} unset so it inherits from the page. */
.pw-toggle{ .pw-strength-label, .pw-match{font-family:'Space Mono',monospace;}
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);}
/* ── Submit ── */ /* ── 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);} .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> <label>Password *</label>
<div class="pw-wrap"> <div class="pw-wrap">
<input type="password" name="password" id="pw" required <input type="password" name="password" id="pw" required
maxlength="128"
placeholder="Min. 8 characters" placeholder="Min. 8 characters"
autocomplete="new-password" autocomplete="new-password"/>
oninput="onPasswordInput()"/>
<button type="button" class="pw-toggle" id="pw-toggle" <button type="button" class="pw-toggle" id="pw-toggle"
onclick="toggleVis('pw','pw-toggle')"
title="Show / hide password" title="Show / hide password"
tabindex="-1"> tabindex="-1">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
@@ -164,28 +113,28 @@
</div> </div>
<!-- Strength bar --> <!-- Strength bar -->
<div class="strength-wrap" id="strength-wrap" style="display:none;"> <div class="pw-strength-wrap" id="strength-wrap" style="display:none;">
<div class="strength-bar-track"> <div class="pw-strength-track">
<div class="strength-bar-fill" id="strength-bar"></div> <div class="pw-strength-fill" id="strength-bar"></div>
</div> </div>
<div class="strength-label" id="strength-label"></div> <div class="pw-strength-label" id="strength-label"></div>
</div> </div>
<!-- Rule checklist — mirrors server-side validate_password() rules --> <!-- Rule checklist — mirrors server-side validate_password() rules -->
<div class="rules" id="rules-box" style="display:none;"> <div class="pw-rules" id="rules-box" style="display:none;" aria-live="polite" aria-atomic="true">
<div class="rule unmet" id="rule-len"> <div class="pw-rule unmet" id="rule-len">
<span class="ri"><i class="bi bi-circle"></i></span> <span class="ri"><i class="bi bi-circle"></i></span>
<span>8+ characters</span> <span>8+ characters</span>
</div> </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 class="ri"><i class="bi bi-circle"></i></span>
<span>Uppercase letter</span> <span>Uppercase letter</span>
</div> </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 class="ri"><i class="bi bi-circle"></i></span>
<span>Number</span> <span>Number</span>
</div> </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 class="ri"><i class="bi bi-circle"></i></span>
<span>Special character</span> <span>Special character</span>
</div> </div>
@@ -197,17 +146,16 @@
<label>Confirm Password *</label> <label>Confirm Password *</label>
<div class="pw-wrap"> <div class="pw-wrap">
<input type="password" name="confirm_password" id="pw2" required <input type="password" name="confirm_password" id="pw2" required
maxlength="128"
placeholder="Repeat password" placeholder="Repeat password"
autocomplete="new-password" autocomplete="new-password"/>
oninput="onConfirmInput()"/>
<button type="button" class="pw-toggle" id="pw2-toggle" <button type="button" class="pw-toggle" id="pw2-toggle"
onclick="toggleVis('pw2','pw2-toggle')"
title="Show / hide password" title="Show / hide password"
tabindex="-1"> tabindex="-1">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</button> </button>
</div> </div>
<div class="match-msg" id="match-msg"></div> <div class="pw-match" id="match-msg" aria-live="polite" aria-atomic="true"></div>
</div> </div>
<button type="submit" class="btn" id="submit-btn"> <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 class="links">Already have an account? <a href="{{ url_for('auth.login') }}">Sign in</a></div>
</div> </div>
<script src="{{ url_for('static', filename='js/password-strength.js') }}"></script>
<script> <script>
// ── Mirrors server-side validate_password() rules in validation_service.py ── const pwStrength = PasswordStrength.init({
// Any change to the server rules must be reflected here too. password : 'pw',
const RULES = { confirm : 'pw2',
len : pw => pw.length >= 8, strengthBar : 'strength-bar',
upper : pw => /[A-Z]/.test(pw), strengthLabel: 'strength-label',
digit : pw => /\d/.test(pw), strengthWrap : 'strength-wrap',
special: pw => /[!@#$%^&*()\-_=+\[\]{};:'",.<>?/\\|`~]/.test(pw), rulesWrap : 'rules-box',
}; rules: { len: 'rule-len', upper: 'rule-upper', digit: 'rule-digit', special: 'rule-special' },
matchMsg : 'match-msg',
// Strength scoring: each rule = 1 point; bonus point for length >= 16 toggles: [
function scorePassword(pw) { { input: 'pw', button: 'pw-toggle' },
let score = 0; { input: 'pw2', button: 'pw2-toggle' },
if (RULES.len(pw)) score++; ],
if (RULES.upper(pw)) score++; onChange: updateSubmitState,
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();
}
function updateSubmitState() { function updateSubmitState() {
const pw = document.getElementById('pw').value; const pw = document.getElementById('pw').value;
const pw2 = document.getElementById('pw2').value; const pw2 = document.getElementById('pw2').value;
const btn = document.getElementById('submit-btn'); 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; const match = pw === pw2 && pw2.length > 0;
// Disable only when user has started typing in either field and criteria // Disable only when the user has started typing in either field and
// are not met — never disable before they've interacted with the fields. // criteria are not met — never disable before they've interacted.
const hasStarted = pw.length > 0 || pw2.length > 0; const hasStarted = pw.length > 0 || pw2.length > 0;
btn.disabled = hasStarted && !(allMet && match); btn.disabled = hasStarted && !(PasswordStrength.allRulesMet(pw) && 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';
} }
</script> </script>
</body> </body>
+79 -32
View File
@@ -6,8 +6,13 @@
<style>:root { --accent: {{ branding.primary_color }}; --accent-h: {{ branding.primary_color }}; }</style> <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://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 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> <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;} *{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;} 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;} .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;} 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{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: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{text-align:center;margin-top:18px;font-size:13px;color:var(--muted);}
.links a{color:var(--accent);} .links a{color:var(--accent);}
.alert{border-radius:8px;padding:11px 14px;font-size:13px;margin-bottom:18px;} .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;} .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> </style>
</head> </head>
<body> <body>
@@ -53,46 +56,90 @@
{% endfor %} {% endfor %}
{% endwith %} {% endwith %}
<form method="POST"> <form method="POST" id="reset-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group"> <div class="form-group">
<label>New Password</label> <label>New Password</label>
<input type="password" name="password" id="pw-new" required placeholder="Min. 8 characters" autofocus <div class="pw-wrap">
oninput="checkStrength(this.value)"/> <input type="password" name="password" id="pw-new" required maxlength="128"
<div style="margin-top:6px;background:#e2e8f0;border-radius:2px;height:4px;"> placeholder="Min. 8 characters" autofocus
<div id="pw-strength-bar"></div> 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>
<div id="pw-strength-text"></div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Confirm Password</label> <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> </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> </form>
<div class="links"><a href="{{ url_for('auth.login') }}">← Back to Sign In</a></div> <div class="links"><a href="{{ url_for('auth.login') }}">← Back to Sign In</a></div>
</div> </div>
<script src="{{ url_for('static', filename='js/password-strength.js') }}"></script>
<script> <script>
function checkStrength(pw) { const pwStrength = PasswordStrength.init({
let score = 0; password : 'pw-new',
if (pw.length >= 8) score++; confirm : 'pw-confirm',
if (/[A-Z]/.test(pw)) score++; strengthBar : 'strength-bar',
if (/[0-9]/.test(pw)) score++; strengthLabel: 'strength-label',
if (/[^A-Za-z0-9]/.test(pw)) score++; strengthWrap : 'strength-wrap',
const bar = document.getElementById('pw-strength-bar'); rulesWrap : 'rules-box',
const text = document.getElementById('pw-strength-text'); rules: { len: 'rule-len', upper: 'rule-upper', digit: 'rule-digit', special: 'rule-special' },
const levels = [ matchMsg : 'match-msg',
{ w: '0%', bg: '#e2e8f0', label: '' }, toggles: [
{ w: '25%', bg: '#dc2626', label: 'Weak' }, { input: 'pw-new', button: 'pw-new-toggle' },
{ w: '50%', bg: '#d97706', label: 'Fair' }, { input: 'pw-confirm', button: 'pw-confirm-toggle' },
{ w: '75%', bg: '#2563eb', label: 'Good' }, ],
{ w: '100%', bg: '#059669', label: 'Strong' }, onChange: updateSubmitState,
]; });
const lvl = levels[score] || levels[0];
bar.style.width = lvl.w; function updateSubmitState() {
bar.style.background = lvl.bg; const pw = document.getElementById('pw-new').value;
text.textContent = lvl.label; const pw2 = document.getElementById('pw-confirm').value;
text.style.color = lvl.bg; 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> </script>
</body> </body>
+1 -1
View File
@@ -141,7 +141,7 @@
<button type="submit" class="btn btn-primary" id="submit-btn"> <button type="submit" class="btn btn-primary" id="submit-btn">
<i class="bi bi-send me-2"></i>Submit Ticket <i class="bi bi-send me-2"></i>Submit Ticket
</button> </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> </div>
</form> </form>
</div> </div>
+7
View File
@@ -53,10 +53,17 @@
Tickets Tickets
<span style="font-size:12px;color:var(--muted);font-family:'Space Mono',monospace;">({{ tickets.total }})</span> <span style="font-size:12px;color:var(--muted);font-family:'Space Mono',monospace;">({{ tickets.total }})</span>
</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"> <a href="{{ url_for('tickets.create_ticket') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>New Ticket <i class="bi bi-plus-lg me-1"></i>New Ticket
</a> </a>
</div> </div>
</div>
<div class="card-body p-0"> <div class="card-body p-0">
{% if tickets.items %} {% if tickets.items %}
<div class="table-responsive"> <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')