Compare commits
10
Commits
18392bf5cd
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c31c7a491 | ||
|
|
d2ea7174ba | ||
|
|
6e2afcb7ce | ||
|
|
f58fb095dd | ||
|
|
19c719ea98 | ||
|
|
c633960e3d | ||
|
|
d27ccb4ddd | ||
|
|
7d94cfd016 | ||
|
|
ea04afe398 | ||
|
|
9bd76dcc91 |
@@ -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
@@ -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'),
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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')
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
@@ -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);
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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 %}
|
||||||
@@ -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; // 0–5
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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')
|
||||||
Reference in New Issue
Block a user