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 6614a9f..a0aff8c 100644 Binary files a/Website_Checker_User_Manual.docx and b/Website_Checker_User_Manual.docx differ 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()