From eab4207e1fdae64fa9d5ffbcc423c373d9b690c5 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Wed, 22 Apr 2026 18:38:57 -0400 Subject: [PATCH] 04/22 user dashboard view, no credentials popup automatically --- CLAUDE.md | 150 +++++++++++++++++++++++++++---- Website_Checker_User_Manual.docx | Bin 21345 -> 21885 bytes app.py | 6 ++ config.ini | 16 +++- config.py | 77 +++++++++++++--- utils/config_crypto.py | 141 +++++++++++++++++++++++++++++ utils/scheduler.py | 8 +- views/admin_websites_view.py | 64 ++++++++++--- views/ai_summary_view.py | 11 ++- views/user_dashboard_view.py | 68 +++++++++++--- 10 files changed, 479 insertions(+), 62 deletions(-) create mode 100644 utils/config_crypto.py diff --git a/CLAUDE.md b/CLAUDE.md index 52c34f6..667a97b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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: ; encrypted +password=dpapi: ; encrypted [email] enabled=false smtp_host=smtp.example.com smtp_port=587 smtp_user=sender@example.com -smtp_password=secret +smtp_password=dpapi: ; encrypted use_tls=true recipients=admin@example.com send_time=18:00 +[groq] +api_key=dpapi: ; encrypted +model=llama-3.3-70b-versatile + [crypto] salt= ``` @@ -145,10 +152,19 @@ E.g. "23456" = Mon–Fri. 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:" 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. diff --git a/Website_Checker_User_Manual.docx b/Website_Checker_User_Manual.docx index 6614a9f17f87608714f3f8570e699e15d8f0e15e..a0aff8c1bd6d0eade993c4d44b3e3255d6e942ba 100644 GIT binary patch literal 21885 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 KuUh&`0P%n25nM3< diff --git a/app.py b/app.py index e6e96fe..b3a0094 100644 --- a/app.py +++ b/app.py @@ -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): diff --git a/config.ini b/config.ini index 8fecba8..c2137be 100644 --- a/config.ini +++ b/config.ini @@ -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 + diff --git a/config.py b/config.py index f242a24..6303789 100644 --- a/config.py +++ b/config.py @@ -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: diff --git a/utils/config_crypto.py b/utils/config_crypto.py new file mode 100644 index 0000000..53f272c --- /dev/null +++ b/utils/config_crypto.py @@ -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:" 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) diff --git a/utils/scheduler.py b/utils/scheduler.py index cab9ca8..af40af1 100644 --- a/utils/scheduler.py +++ b/utils/scheduler.py @@ -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: diff --git a/views/admin_websites_view.py b/views/admin_websites_view.py index 5dc2f86..5fa8c7f 100644 --- a/views/admin_websites_view.py +++ b/views/admin_websites_view.py @@ -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) diff --git a/views/ai_summary_view.py b/views/ai_summary_view.py index 3939588..c94486b 100644 --- a/views/ai_summary_view.py +++ b/views/ai_summary_view.py @@ -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) diff --git a/views/user_dashboard_view.py b/views/user_dashboard_view.py index 8360cf1..35dda01 100644 --- a/views/user_dashboard_view.py +++ b/views/user_dashboard_view.py @@ -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()