04/22 user dashboard view, no credentials popup automatically

This commit is contained in:
2026-04-22 18:38:57 -04:00
parent b7e2a164c7
commit eab4207e1f
10 changed files with 479 additions and 62 deletions
+132 -18
View File
@@ -29,7 +29,8 @@ website_checker/
├── requirements.txt
├── CLAUDE.md This file
├── utils/
│ ├── crypto.py Fernet credential encryption
│ ├── config_crypto.py Windows DPAPI encryption for config.ini credentials
│ ├── crypto.py Fernet credential encryption (website passwords)
│ ├── export.py CSV + Excel export
│ ├── scheduler.py Daily email report background daemon
│ └── ui_helpers.py ThemeManager, COLOURS proxy, DateEntry, widgets
@@ -38,7 +39,8 @@ website_checker/
├── admin_log_view.py Activity log treeview
├── admin_shifts_view.py Shift CRUD + PDF export
├── admin_users_view.py User CRUD
├── admin_websites_view.py Website CRUD + visibility + credentials
├── admin_websites_view.py Website CRUD + visibility + credentials (collapsible)
├── ai_summary_view.py AI document analysis via Groq API (NEW)
├── change_password_view.py Self-service password change (all roles)
├── email_settings_view.py SMTP / scheduled report configuration
├── login_view.py Login form with rate-limiting countdown
@@ -52,26 +54,31 @@ website_checker/
## 3. Runtime Files
### `config.ini`
Created on first launch. Contains [database], [email], and [crypto] sections.
Created on first launch. Contains [database], [email], [crypto], and [groq] sections.
**Sensitive fields are DPAPI-encrypted** (see §6 Security). Encrypted values have a `dpapi:` prefix.
```ini
[database]
host=your-mysql-host
port=3306
database=website_checker
user=your-db-user
password=your-db-password
user=dpapi:<base64-blob> ; encrypted
password=dpapi:<base64-blob> ; encrypted
[email]
enabled=false
smtp_host=smtp.example.com
smtp_port=587
smtp_user=sender@example.com
smtp_password=secret
smtp_password=dpapi:<base64-blob> ; encrypted
use_tls=true
recipients=admin@example.com
send_time=18:00
[groq]
api_key=dpapi:<base64-blob> ; encrypted
model=llama-3.3-70b-versatile
[crypto]
salt=<base64 32-byte salt — auto-generated>
```
@@ -145,10 +152,19 @@ E.g. "23456" = MonFri. Queried with `LOCATE(DAYOFWEEK(CURDATE()), days_of_wee
- DB_CONFIG loaded from config.ini at import; placeholders if file missing
- get_connection() → pool (pool_size=5)
- initialize_database() → idempotent DDL + ALTER TABLE migrations
- load_config() / save_config() → config.ini read/write
- load_config() / save_config() → decrypt/encrypt DPAPI fields transparently
- migrate_plaintext_config() → one-time migration; encrypts any plain-text creds on startup
- config_exists() → True if host, database, user are set
- reload_db_config() → re-reads config.ini, resets pool
### utils/config_crypto.py *(NEW)*
Windows DPAPI-based encryption for config.ini sensitive values.
- encrypt_value(plaintext) → "dpapi:<base64>" string
- decrypt_value(stored) → plaintext; plain-text pass-through for legacy values
- is_encrypted(value) → True if value starts with "dpapi:"
- Tied to the current Windows user account — blob is unreadable on any other machine/account
- Graceful fallback: if pywin32 not available, values stored/returned as plain text
### models.py
Each function opens+closes its own connection. All writes call log_action().
@@ -177,13 +193,37 @@ Password strength: PW_MIN_LENGTH=8, requires upper + digit + special char
### utils/scheduler.py
- Daemon thread; polls every 60 seconds
- Sends HTML email once per day when now >= send_time
- smtp_password is DPAPI-encrypted on save (encrypt_value) / decrypted on load (decrypt_value)
- last_sent_date in-memory (resets on restart)
- start() / stop() called from app.py on login/logout (admin only)
### views/login_view.py
- check_login_allowed(username) called before authenticate()
- Locked: form disabled; 1-second countdown; re-enables when timer reaches 0
- Not-yet-locked failed attempt: shows "N attempt(s) remaining before lockout"
### views/ai_summary_view.py *(NEW)*
AI-powered document analysis panel. Accessible from the sidebar for both admin and user roles.
Key internals:
- Supported file types: .txt, .md, .csv, .pdf, .docx, .doc, .xlsx, .xls
- Groq API key and model selection are **visible only to admins** (user role sees neither)
- Regular users use the stored API key transparently
- Background thread for AI calls (prevents UI freeze)
- Extraction prompt focused on procurement/solicitation fields:
Solicitation Number/Type, Set-Aside, Description, Work Site, Pre-Proposal Conference,
POC, Square Footage, Driving Distance from office (2815 Hartland Rd, Falls Church VA),
Last Day for Questions, Due Date
- Overall summary covers: Scope of Work, Contract Period, Proposal Submission Requirements,
Key Deadlines & Action Items
- Groq API key stored DPAPI-encrypted in config.ini [groq] section
- max_tokens=4096; temperature=0.2; MAX_CHARS_PER_FILE=14,000
.doc reading (legacy binary Word format) — 3-tier fallback:
1. win32com (Word COM automation — requires MS Word installed)
2. docx2txt (pure Python)
3. Raw ASCII scrape from binary
### views/admin_websites_view.py
- Website CRUD dialog now has a **collapsible credentials section** (hidden by default)
- "🔑 Show Credentials" / "🔒 Hide Credentials" toggle button
- Auto-expands when editing a site that already has saved credentials
- " Add Credential" auto-expands the section if collapsed
### views/user_dashboard_view.py
Key internals:
@@ -192,6 +232,15 @@ Key internals:
- Mousewheel: Enter/Leave scoped; unbind_all in destroy()
- Keyboard shortcuts: self.bind() stored in _shortcut_ids; unbound in destroy()
- Notifications: after(60_000) loop; plyer first, fallback to borderless Toplevel toast
- **Credentials popup no longer opens automatically on link click**
- "🔑 Credentials" button appears on each site card only if the site has saved credentials
- CredentialsPopup: 📋 copy button for username (2s flash); 📋 copy button for password
(2s flash + clipboard auto-cleared after 15s for security); 👁 toggle to reveal password
### views/login_view.py
- check_login_allowed(username) called before authenticate()
- Locked: form disabled; 1-second countdown; re-enables when timer reaches 0
- Not-yet-locked failed attempt: shows "N attempt(s) remaining before lockout"
### views/admin_shifts_view.py
- _export_pdf(): reportlab A4 document; one section per active shift; user+website tables
@@ -207,11 +256,19 @@ Key internals:
| Concern | Implementation |
|---|---|
| Password hashing | bcrypt rounds=12; SHA-256 auto-rehashed on next login |
| Credential encryption | Fernet (AES-128-CBC + HMAC) via cryptography library |
| Website credential encryption | Fernet (AES-128-CBC + HMAC) via cryptography library |
| Config credential protection | Windows DPAPI (CryptProtectData) — user-account-scoped |
| Encrypted fields | DB user, DB password, SMTP password, Groq API key |
| Login rate limiting | 5 attempts → 15-min lockout in DB |
| Session timeout | 30-min idle; 1-min warning; any mouse/key event resets |
| Password policy | 8+ chars, uppercase, digit, special character |
| Self-service change | Requires current password; strength meter; same-as-current guard |
| Clipboard security | Password copy auto-clears clipboard after 15 seconds |
### DPAPI Migration
`migrate_plaintext_config()` is called automatically on every startup (in `_init_db`).
It is a no-op if all sensitive fields are already encrypted (dpapi: prefix present).
On first run after this feature was added, it encrypts any existing plain-text values in-place.
---
@@ -249,6 +306,8 @@ Websites and Shifts: is_active=0. Users: hard-delete (admin can deactivate first
| user_dashboard_view.py | NOTIFY_MINUTES_BEFORE | 15 |
| admin_dashboard_view.py | REFRESH_INTERVAL_MS | 60,000 |
| utils/crypto.py | _ITERATIONS | 100,000 |
| ai_summary_view.py | MAX_CHARS_PER_FILE | 14,000 |
| ai_summary_view.py | _OFFICE_ADDRESS | "2815 Hartland Road, Falls Church, VA 22043, USA" |
---
@@ -262,8 +321,9 @@ Websites and Shifts: is_active=0. Users: hard-delete (admin can deactivate first
3. bcrypt is slow by design (~200-400ms at rounds=12). Expected behaviour.
4. config.ini stores DB password and SMTP password in plaintext.
Website credential passwords are Fernet-encrypted.
4. config.ini sensitive fields are DPAPI-encrypted (dpapi: prefix).
Website credential passwords are Fernet-encrypted (enc: prefix).
Non-sensitive fields (host, port, db name, smtp_host, recipients) remain plain text.
5. get_today_checks GROUP BY includes sc.id to prevent row collisions when a user
belongs to multiple shifts sharing the same website.
@@ -276,6 +336,13 @@ Websites and Shifts: is_active=0. Users: hard-delete (admin can deactivate first
8. All log messages use ASCII only (hyphens not em-dashes/arrows) to prevent
cp1252 UnicodeEncodeError on Windows consoles.
9. DPAPI encrypted blobs are tied to the Windows user account that created them.
If config.ini is copied to a different machine or user account, credentials
cannot be decrypted. Users must re-enter credentials via the Settings dialog.
10. The AI summary view fetches credentials from the DB at card render time (not lazily).
Avoid having hundreds of sites with credentials as this adds DB round-trips per render.
---
## 10. Dependencies
@@ -288,9 +355,15 @@ cryptography>=41.0.0
matplotlib>=3.7.0
plyer>=2.1.0
reportlab>=4.0.0
groq>=1.0.0
pypdf>=3.0.0
python-docx>=1.0.0
pywin32>=306
docx2txt>=0.8
```
stdlib used: tkinter, csv, smtplib, urllib.request, configparser, threading, calendar, datetime
stdlib used: tkinter, csv, smtplib, urllib.request, configparser, threading, calendar,
datetime, base64, re, tempfile
---
@@ -299,15 +372,56 @@ stdlib used: tkinter, csv, smtplib, urllib.request, configparser, threading, cal
1. _boot() → config_exists() → False → SettingsView (locked modal)
2. User enters DB creds → Test Connection → Save & Connect
3. reload_db_config() → initialize_database() → seeds admin/admin123 if users empty
4. Login screen shown
5. ThemeManager(root, initial="light") applied; shell built
4. migrate_plaintext_config() → encrypts any plain-text credentials in config.ini
5. Login screen shown
6. ThemeManager(root, initial="light") applied; shell built
---
## 12. Deployment Notes
## 12. Sidebar Navigation
### Admin role
Dashboard · Websites · Users · Shifts · Reports · Activity Log · 🤖 AI Summary · Settings · Sign Out
### User role
My Shift · 🤖 AI Summary · Change Password · Sign Out
---
## 13. Deployment Notes
- Python 3.9+ required. 3.12 tested on Windows.
- tkinter bundled on Windows/macOS; Linux: apt install python3-tk
- Create DB first: CREATE DATABASE website_checker CHARACTER SET utf8mb4;
- MySQL port 3306 must be reachable from client
- Default admin: username=admin / password=admin123 — change immediately
- pywin32 required for DPAPI encryption (Windows only). Install: pip install pywin32
- Microsoft Word recommended for .doc file support in AI Summary (falls back to docx2txt)
- Groq API key required for AI Summary feature — free at https://console.groq.com
---
## 14. AI Summary Feature — Setup & Prompt Details
### Setup (Admin)
1. Obtain a free Groq API key at https://console.groq.com
2. Navigate to **🤖 AI Summary** in the sidebar
3. Enter the API key and select a model
4. Click **💾 Save Settings** — key is stored DPAPI-encrypted in config.ini
### Supported Models
- llama-3.3-70b-versatile (default, recommended)
- llama-3.1-8b-instant
- gemma2-9b-it
- mixtral-8x7b-32768
### Prompt Strategy
The prompt instructs the AI to extract 12 labeled fields per document:
Solicitation Number, Type, Set-Aside, Description/Scope, Work Site, Pre-Proposal Conference,
POC, Square Footage, **Driving Distance from 2815 Hartland Rd Falls Church VA** (calculated by AI),
Last Day for Questions, Due Date, Other requirements.
Then produces an Overall Summary with 4 sections:
A. Scope of Work · B. Contract Period · C. Proposal Submission Requirements · D. Key Deadlines
The driving distance/time is an AI estimate using major highways. Actual times may vary with traffic.
Binary file not shown.
+6
View File
@@ -75,6 +75,12 @@ class App(tk.Tk):
# Re-open setup so the user can correct the credentials
self._show_setup(on_complete=self._init_db)
return
# Encrypt any plain-text credentials in config.ini (one-time migration)
try:
from config import migrate_plaintext_config
migrate_plaintext_config()
except Exception as e:
logger.warning(f"Config migration skipped: {e}")
self._show_login()
def _show_login(self):
+13 -3
View File
@@ -2,13 +2,23 @@
host = 67.217.62.199
port = 3306
database = webchecker
user = webchecker
password = 7x+MxGmks_3U
user = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAm1sPKS5A3aczt5vkmqeO+XXJLmKwA1ojVXeBzQZ9QIwAAAAADoAAAAACAAAgAAAA6CLkACCqBm32SUXekn49rHMseysgdxMtdhywY0+zxO4QAAAA6KGNWrsRGwpImEXQZfuQikAAAABHFvkj2Z2choaPIoFwNryk31E6oZfI11uv/Ms5eIrN0uSC9VSdjYC9IU7SKCkK2z7BYQB2Uy9QoXZF6VFDXE/r
password = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAO3bOCycWnlLWFscvK6zmWysxARgAyABh7eIYDzK/KN8AAAAADoAAAAACAAAgAAAAFU2hwhMfmMnWGTZL16cWfpc2rs5/Oy1lq7aEqeC5GcUQAAAAExqVIbNsn/vH99tw8NMfBUAAAAA+pf+tJVrYOMikcjrPcFLRKS+dR6+4OGBj/BiM4IgR07Zbk8GNxddhdb9Hg8/2D51oaywFNV55lDd9aJcrtnHS
[crypto]
salt = GE453Sa0afGLfzq8EPKdXjvZyAzK499XzWxWq/ZdnFQ=
[groq]
api_key = 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
api_key = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAvjBskxdtcNpYs3pBI8ISyRDRKIgTpRQf39CfrILduhUAAAAADoAAAAACAAAgAAAAdkFkBiC1kD4X0L3a6eoe8z2s+O/KYriTZfl8L6qOc+JAAAAABW4zpNffQtP5IMFXi3W/93xl9XCt3lbTxegcdqxOxVXrp1hRyolT5YPFdB1gjL53ywCDA+Ls4yRKGMBekl49Q0AAAAAms4kvJoK2EhZzfvcHkvDhoixVelv89fLw2ZS1yKPtMrNJFaoGd+IkOCcLAnI6eMiy5PYtPe2t2vtlZ09i1nJZ
model = llama-3.3-70b-versatile
[email]
enabled = true
smtp_host = mail.ltservicesinc.com
smtp_port = 465
smtp_user = donotreply@ltservicesinc.com
smtp_password = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAMimxQU449tZCXoe+d12bn9uAi6ZwuSzZGg7Mg/XYa5sAAAAADoAAAAACAAAgAAAAAnuQcfyetLSl/SIMvKqsb9GPv8e0Boh2/KUMnCKrtVcQAAAAU2cmO41/lsCJ3CBdnIYo1EAAAACXyd4+ESZhFnrZKdgWbDCwzVRSRyuOn37sdNpFrHRmGzD3Zy8/FivfcNBbFnfJNheVpeKsld+wnGfOlxiW0Tmm
use_tls = true
recipients = da.nguyen8744@gmail.com
send_time = 18:00
+66 -11
View File
@@ -42,12 +42,18 @@ APP_TITLE = "Website Checker"
APP_VERSION = "1.0.0"
# Sensitive fields that are DPAPI-encrypted in config.ini
_DB_SENSITIVE = {"user", "password"}
def load_config() -> dict:
"""
Load DB settings from config.ini.
Returns a dict with keys: host, port, database, user, password.
Sensitive fields (user, password) are decrypted transparently via DPAPI.
Returns empty dict if the file does not exist or is incomplete.
"""
from utils.config_crypto import decrypt_value
cfg = _cp.ConfigParser()
if not _os.path.exists(CONFIG_FILE):
return {}
@@ -55,28 +61,77 @@ def load_config() -> dict:
if "database" not in cfg:
return {}
section = cfg["database"]
return {
"host": section.get("host", ""),
"port": section.getint("port", 3306),
"database": section.get("database", ""),
"user": section.get("user", ""),
"password": section.get("password", ""),
}
try:
return {
"host": section.get("host", ""),
"port": section.getint("port", 3306),
"database": section.get("database", ""),
"user": decrypt_value(section.get("user", "")),
"password": decrypt_value(section.get("password", "")),
}
except RuntimeError as exc:
logger.error(f"Failed to decrypt DB credentials: {exc}")
raise
def save_config(host: str, port: int, database: str, user: str, password: str):
"""Persist DB connection settings to config.ini."""
"""Persist DB connection settings to config.ini (sensitive fields DPAPI-encrypted)."""
from utils.config_crypto import encrypt_value
# Preserve any existing non-database sections (email, crypto, groq)
cfg = _cp.ConfigParser()
if _os.path.exists(CONFIG_FILE):
cfg.read(CONFIG_FILE, encoding="utf-8")
cfg["database"] = {
"host": host,
"port": str(port),
"database": database,
"user": user,
"password": password,
"user": encrypt_value(user),
"password": encrypt_value(password),
}
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
cfg.write(fh)
logger.info(f"Configuration saved to {CONFIG_FILE}.")
logger.info(f"Configuration saved to {CONFIG_FILE} (credentials encrypted).")
def migrate_plaintext_config():
"""
One-time migration: if config.ini contains plain-text DB credentials
(no 'dpapi:' prefix) encrypt them in-place using Windows DPAPI.
Safe to call on every startup — is a no-op when already encrypted.
"""
from utils.config_crypto import encrypt_value, is_encrypted
if not _os.path.exists(CONFIG_FILE):
return
cfg = _cp.ConfigParser()
cfg.read(CONFIG_FILE, encoding="utf-8")
changed = False
# DB section
for key in ("user", "password"):
if cfg.has_option("database", key):
raw = cfg.get("database", key)
if raw and not is_encrypted(raw):
cfg.set("database", key, encrypt_value(raw))
changed = True
# Email section
if cfg.has_option("email", "smtp_password"):
raw = cfg.get("email", "smtp_password")
if raw and not is_encrypted(raw):
cfg.set("email", "smtp_password", encrypt_value(raw))
changed = True
# Groq section
if cfg.has_option("groq", "api_key"):
raw = cfg.get("groq", "api_key")
if raw and not is_encrypted(raw):
cfg.set("groq", "api_key", encrypt_value(raw))
changed = True
if changed:
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
cfg.write(fh)
logger.info("config.ini: plain-text credentials encrypted with Windows DPAPI.")
def config_exists() -> bool:
+141
View File
@@ -0,0 +1,141 @@
"""
utils/config_crypto.py Transparent DPAPI encryption for config.ini values.
Uses Windows Data Protection API (CryptProtectData / CryptUnprotectData)
via pywin32. Encrypted values are stored with a "dpapi:" prefix in
config.ini so the format is self-documenting.
Key properties:
- Tied to the current Windows USER account (not just the machine).
The encrypted blob is completely unreadable on any other machine or
under any other Windows account.
- No key to manage, distribute, or store Windows manages the key
transparently via the user's password-derived master key.
- Graceful fallback: if pywin32 is not available (e.g. running on a
dev Linux box), values are stored/returned as plain text with a
warning. This keeps the dev workflow intact.
Usage
-----
from utils.config_crypto import encrypt_value, decrypt_value
stored = encrypt_value("my-secret") # "dpapi:AAAA..."
secret = decrypt_value(stored) # "my-secret"
# For values that may already be plain text (migration):
secret = decrypt_value("plain-text") # "plain-text" (no-op)
"""
import base64
import logging
logger = logging.getLogger("config_crypto")
_DPAPI_PREFIX = "dpapi:"
# ---------------------------------------------------------------------------
# Low-level DPAPI wrappers
# ---------------------------------------------------------------------------
def _dpapi_protect(plaintext: str) -> bytes:
"""Encrypt a string with Windows DPAPI (current-user scope)."""
import win32crypt # noqa: PLC0415
data = plaintext.encode("utf-8")
encrypted = win32crypt.CryptProtectData(
data,
"WebChecker config", # optional descriptive label
None, # optional entropy (None = no extra entropy)
None, # reserved
None, # no UI prompt
0, # flags: 0 = user-scope (default)
)
return encrypted
def _dpapi_unprotect(ciphertext_bytes: bytes) -> str:
"""Decrypt bytes produced by _dpapi_protect."""
import win32crypt # noqa: PLC0415
_desc, plaintext_bytes = win32crypt.CryptUnprotectData(
ciphertext_bytes,
None, # optional entropy
None, # reserved
None, # no UI prompt
0, # flags
)
return plaintext_bytes.decode("utf-8")
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def encrypt_value(plaintext: str) -> str:
"""
Encrypt *plaintext* and return a "dpapi:<base64>" string suitable for
storage in config.ini.
If the value is already encrypted (starts with "dpapi:"), it is returned
unchanged. If DPAPI is unavailable, the plain value is returned with a
warning.
"""
if not plaintext:
return plaintext
if plaintext.startswith(_DPAPI_PREFIX):
return plaintext # already encrypted
try:
ciphertext = _dpapi_protect(plaintext)
return _DPAPI_PREFIX + base64.b64encode(ciphertext).decode("ascii")
except ImportError:
logger.warning(
"pywin32 not available — config values stored as plain text. "
"Install pywin32 for credential protection."
)
return plaintext
except Exception as exc:
logger.error(f"DPAPI encryption failed: {exc}. Storing plain text.")
return plaintext
def decrypt_value(stored: str) -> str:
"""
Decrypt a value returned by encrypt_value().
- If *stored* starts with "dpapi:", it is decrypted and the plaintext
is returned.
- Otherwise the value is assumed to be plain text and returned as-is
(handles legacy / non-Windows environments).
Raises RuntimeError if the DPAPI decryption fails (e.g. wrong user
account or corrupted data).
"""
if not stored:
return stored
if not stored.startswith(_DPAPI_PREFIX):
return stored # plain text — pass through (legacy / no-DPAPI env)
b64_part = stored[len(_DPAPI_PREFIX):]
try:
ciphertext = base64.b64decode(b64_part)
return _dpapi_unprotect(ciphertext)
except ImportError:
logger.warning(
"pywin32 not available — cannot decrypt DPAPI value. "
"Returning raw stored value."
)
return stored
except Exception as exc:
raise RuntimeError(
f"Failed to decrypt a protected config value.\n\n"
f"This usually means the config.ini was created by a different "
f"Windows user account or on a different machine.\n\n"
f"Please re-enter your settings in the Settings dialog.\n\n"
f"Technical detail: {exc}"
) from exc
def is_encrypted(value: str) -> bool:
"""Return True if *value* has already been DPAPI-encrypted."""
return value.startswith(_DPAPI_PREFIX)
+5 -3
View File
@@ -38,6 +38,7 @@ _stop_event = threading.Event()
# ─── Config helpers ───────────────────────────────────────────────────────────
def load_email_config() -> dict:
from utils.config_crypto import decrypt_value
cfg = configparser.ConfigParser()
if not os.path.exists(CONFIG_FILE):
return {}
@@ -50,7 +51,7 @@ def load_email_config() -> dict:
"smtp_host": s.get("smtp_host", ""),
"smtp_port": s.getint("smtp_port", fallback=587),
"smtp_user": s.get("smtp_user", ""),
"smtp_password": s.get("smtp_password", ""),
"smtp_password": decrypt_value(s.get("smtp_password", "")),
"use_tls": s.getboolean("use_tls", fallback=True),
"recipients": [r.strip() for r in s.get("recipients", "").split(",") if r.strip()],
"send_time": s.get("send_time", "18:00"),
@@ -60,6 +61,7 @@ def load_email_config() -> dict:
def save_email_config(enabled: bool, smtp_host: str, smtp_port: int,
smtp_user: str, smtp_password: str, use_tls: bool,
recipients: str, send_time: str):
from utils.config_crypto import encrypt_value
cfg = configparser.ConfigParser()
cfg.read(CONFIG_FILE, encoding="utf-8")
cfg["email"] = {
@@ -67,14 +69,14 @@ def save_email_config(enabled: bool, smtp_host: str, smtp_port: int,
"smtp_host": smtp_host,
"smtp_port": str(smtp_port),
"smtp_user": smtp_user,
"smtp_password": smtp_password,
"smtp_password": encrypt_value(smtp_password),
"use_tls": str(use_tls).lower(),
"recipients": recipients,
"send_time": send_time,
}
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
cfg.write(fh)
logger.info("Email configuration saved.")
logger.info("Email configuration saved (password encrypted).")
def test_smtp_connection(smtp_host, smtp_port, smtp_user, smtp_password, use_tls) -> tuple:
+52 -12
View File
@@ -288,18 +288,34 @@ class WebsiteDialog(tk.Toplevel):
# Hide initially; shown when visibility='assigned'
self._user_assign_frame.pack_forget()
# ── Credentials section ───────────────────────────────────────────────
# ── Credentials section (collapsed by default) ───────────────────────
ttk.Separator(self.inner, orient="horizontal").pack(
fill="x", padx=24, pady=12)
cred_header = ttk.Frame(self.inner)
cred_header.pack(fill="x", padx=24)
ttk.Label(cred_header, text="Login Credentials",
style="Heading.TLabel").pack(side="left")
ttk.Button(cred_header, text=" Add Credential",
command=self._add_cred_row).pack(side="right")
self.creds_container = ttk.Frame(self.inner)
self.creds_container.pack(fill="x", padx=24, pady=8)
# Toggle header row
cred_toggle = ttk.Frame(self.inner)
cred_toggle.pack(fill="x", padx=24)
ttk.Label(cred_toggle, text="Login Credentials",
style="Heading.TLabel").pack(side="left")
self._cred_expanded = False
self._cred_toggle_btn = ttk.Button(
cred_toggle,
text="🔑 Show Credentials",
style="Ghost.TButton",
command=self._toggle_credentials,
)
self._cred_toggle_btn.pack(side="right")
ttk.Button(cred_toggle, text=" Add Credential",
command=self._add_cred_row_visible).pack(side="right", padx=(0, 6))
# Collapsible body
self._cred_body = ttk.Frame(self.inner)
self.creds_container = ttk.Frame(self._cred_body)
self.creds_container.pack(fill="x")
# _cred_body is NOT packed initially — hidden by default
# ── Buttons ───────────────────────────────────────────────────────────
ttk.Separator(self.inner, orient="horizontal").pack(
@@ -325,10 +341,13 @@ class WebsiteDialog(tk.Toplevel):
for uid, var in self._user_vars.items():
var.set(uid in assigned_ids)
self._on_visibility_change()
for cred in d.get("credentials", []):
existing_creds = d.get("credentials", [])
for cred in existing_creds:
self._add_cred_row(cred)
else:
self._add_cred_row()
# Auto-expand credentials section if creds already exist
if existing_creds:
self._expand_credentials()
# For new websites: credentials section stays collapsed
def _on_visibility_change(self):
"""Show or hide the user assignment panel based on visibility selection."""
@@ -338,6 +357,27 @@ class WebsiteDialog(tk.Toplevel):
else:
self._user_assign_frame.pack_forget()
def _expand_credentials(self):
"""Show the credentials body and update the toggle button label."""
self._cred_expanded = True
self._cred_body.pack(fill="x", padx=24, pady=(4, 0))
self._cred_toggle_btn.config(text="🔒 Hide Credentials")
def _toggle_credentials(self):
"""Show or hide the collapsible credentials section."""
if self._cred_expanded:
self._cred_expanded = False
self._cred_body.pack_forget()
self._cred_toggle_btn.config(text="🔑 Show Credentials")
else:
self._expand_credentials()
def _add_cred_row_visible(self):
"""Expand the section (if collapsed) then add an empty credential row."""
if not self._cred_expanded:
self._expand_credentials()
self._add_cred_row()
def _add_cred_row(self, cred=None):
frame = ttk.Frame(self.creds_container, style="Surface.TFrame")
frame.pack(fill="x", pady=4, ipady=4)
+7 -4
View File
@@ -156,11 +156,12 @@ class AiSummaryView(ttk.Frame):
try:
import configparser
from config import CONFIG_FILE
from utils.config_crypto import decrypt_value
cfg = configparser.ConfigParser()
cfg.read(CONFIG_FILE, encoding="utf-8")
if cfg.has_section(_CFG_SECTION):
self._api_key_var.set(
cfg.get(_CFG_SECTION, _CFG_KEY_KEY, fallback=""))
raw_key = cfg.get(_CFG_SECTION, _CFG_KEY_KEY, fallback="")
self._api_key_var.set(decrypt_value(raw_key))
model = cfg.get(_CFG_SECTION, _CFG_KEY_MODEL,
fallback=GROQ_MODELS[0])
if model in GROQ_MODELS:
@@ -169,15 +170,17 @@ class AiSummaryView(ttk.Frame):
logger.warning(f"Could not load Groq config: {e}")
def _save_config(self):
"""Persist API key / model to config.ini [groq] section."""
"""Persist API key / model to config.ini [groq] section (key encrypted)."""
try:
import configparser
from config import CONFIG_FILE
from utils.config_crypto import encrypt_value
cfg = configparser.ConfigParser()
cfg.read(CONFIG_FILE, encoding="utf-8")
if not cfg.has_section(_CFG_SECTION):
cfg.add_section(_CFG_SECTION)
cfg.set(_CFG_SECTION, _CFG_KEY_KEY, self._api_key_var.get().strip())
cfg.set(_CFG_SECTION, _CFG_KEY_KEY,
encrypt_value(self._api_key_var.get().strip()))
cfg.set(_CFG_SECTION, _CFG_KEY_MODEL, self._model_var.get())
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
cfg.write(fh)
+57 -11
View File
@@ -362,7 +362,24 @@ class UserDashboardView(ttk.Frame):
activeforeground=COLOURS["accent"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=10, pady=4,
).pack()
).pack(pady=(0, 6))
# 🔑 Credentials button — only shown if this site has saved credentials
try:
from models import get_website_credentials
creds = get_website_credentials(wid)
except Exception:
creds = []
if creds:
tk.Button(
btn_frame, text="🔑 Credentials",
command=lambda s=site, c=creds: CredentialsPopup(self, s["name"], c),
bg=COLOURS["surface2"], fg=COLOURS["text"],
activebackground=COLOURS["accent"],
activeforeground=COLOURS["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=10, pady=4,
).pack()
# ─── Bulk Actions ─────────────────────────────────────────────────────────
@@ -507,11 +524,6 @@ class UserDashboardView(ttk.Frame):
except Exception as e:
show_error(f"Could not open URL:\n{e}")
from models import get_website_credentials
creds = get_website_credentials(site["id"])
if creds:
CredentialsPopup(self, site["name"], creds)
def _mark_checked(self, site: dict):
try:
from models import mark_website_checked
@@ -647,22 +659,39 @@ class CredentialsPopup(tk.Toplevel):
label = cred.get("label") or "Default"
tk.Label(frame, text=label, font=FONT_BOLD,
bg=COLOURS["surface"], fg=COLOURS["accent"]).grid(
row=0, column=0, columnspan=4, sticky="w", padx=10, pady=(4, 2))
row=0, column=0, columnspan=6, sticky="w", padx=10, pady=(4, 2))
# ── Username row ──────────────────────────────────────────────────
tk.Label(frame, text="Username:", bg=COLOURS["surface"],
fg=COLOURS["text_dim"]).grid(
row=1, column=0, sticky="w", padx=(10, 4))
tk.Label(frame, text=cred["username"], bg=COLOURS["surface"],
fg=COLOURS["text"], font=FONT_BOLD).grid(
row=1, column=1, sticky="w", padx=(0, 20))
row=1, column=1, sticky="w", padx=(0, 8))
user_copy_btn = tk.Button(
frame, text="📋", width=3,
bg=COLOURS["surface2"], fg=COLOURS["text_dim"],
activebackground=COLOURS["accent"], activeforeground=COLOURS["white"],
relief="flat", cursor="hand2", font=FONT_SMALL,
)
user_copy_btn.grid(row=1, column=2, padx=(0, 16))
def _copy_user(c=cred, b=user_copy_btn):
self.clipboard_clear()
self.clipboard_append(c["username"])
b.config(text="", fg=COLOURS["success"])
self.after(2000, lambda: b.config(text="📋", fg=COLOURS["text_dim"]))
user_copy_btn.config(command=_copy_user)
# ── Password row ──────────────────────────────────────────────────
tk.Label(frame, text="Password:", bg=COLOURS["surface"],
fg=COLOURS["text_dim"]).grid(
row=1, column=2, sticky="w", padx=(0, 4))
row=1, column=3, sticky="w", padx=(0, 4))
pw_var = tk.StringVar(value="••••••••")
tk.Label(frame, textvariable=pw_var, bg=COLOURS["surface"],
fg=COLOURS["text"], font=FONT_BOLD).grid(
row=1, column=3, sticky="w")
row=1, column=4, sticky="w", padx=(0, 4))
revealed = [False]
def toggle(c=cred, v=pw_var, r=revealed):
@@ -671,7 +700,24 @@ class CredentialsPopup(tk.Toplevel):
tk.Button(frame, text="👁", command=toggle,
bg=COLOURS["surface"], fg=COLOURS["text_dim"],
relief="flat", cursor="hand2").grid(
row=1, column=4, padx=(8, 10))
row=1, column=5, padx=(0, 4))
pw_copy_btn = tk.Button(
frame, text="📋", width=3,
bg=COLOURS["surface2"], fg=COLOURS["text_dim"],
activebackground=COLOURS["accent"], activeforeground=COLOURS["white"],
relief="flat", cursor="hand2", font=FONT_SMALL,
)
pw_copy_btn.grid(row=1, column=6, padx=(0, 10))
def _copy_pw(c=cred, b=pw_copy_btn):
self.clipboard_clear()
self.clipboard_append(c["password"])
b.config(text="", fg=COLOURS["success"])
# Auto-clear clipboard after 15 s for security
self.after(15_000, self.clipboard_clear)
self.after(2000, lambda: b.config(text="📋", fg=COLOURS["text_dim"]))
pw_copy_btn.config(command=_copy_pw)
ttk.Button(self, text="Close", command=self.destroy).pack(pady=16)
self._centre()