04/22 user dashboard view, no credentials popup automatically
This commit is contained in:
@@ -29,7 +29,8 @@ website_checker/
|
|||||||
├── requirements.txt
|
├── requirements.txt
|
||||||
├── CLAUDE.md This file
|
├── CLAUDE.md This file
|
||||||
├── utils/
|
├── 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
|
│ ├── export.py CSV + Excel export
|
||||||
│ ├── scheduler.py Daily email report background daemon
|
│ ├── scheduler.py Daily email report background daemon
|
||||||
│ └── ui_helpers.py ThemeManager, COLOURS proxy, DateEntry, widgets
|
│ └── ui_helpers.py ThemeManager, COLOURS proxy, DateEntry, widgets
|
||||||
@@ -38,7 +39,8 @@ website_checker/
|
|||||||
├── admin_log_view.py Activity log treeview
|
├── admin_log_view.py Activity log treeview
|
||||||
├── admin_shifts_view.py Shift CRUD + PDF export
|
├── admin_shifts_view.py Shift CRUD + PDF export
|
||||||
├── admin_users_view.py User CRUD
|
├── 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)
|
├── change_password_view.py Self-service password change (all roles)
|
||||||
├── email_settings_view.py SMTP / scheduled report configuration
|
├── email_settings_view.py SMTP / scheduled report configuration
|
||||||
├── login_view.py Login form with rate-limiting countdown
|
├── login_view.py Login form with rate-limiting countdown
|
||||||
@@ -52,26 +54,31 @@ website_checker/
|
|||||||
## 3. Runtime Files
|
## 3. Runtime Files
|
||||||
|
|
||||||
### `config.ini`
|
### `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
|
```ini
|
||||||
[database]
|
[database]
|
||||||
host=your-mysql-host
|
host=your-mysql-host
|
||||||
port=3306
|
port=3306
|
||||||
database=website_checker
|
database=website_checker
|
||||||
user=your-db-user
|
user=dpapi:<base64-blob> ; encrypted
|
||||||
password=your-db-password
|
password=dpapi:<base64-blob> ; encrypted
|
||||||
|
|
||||||
[email]
|
[email]
|
||||||
enabled=false
|
enabled=false
|
||||||
smtp_host=smtp.example.com
|
smtp_host=smtp.example.com
|
||||||
smtp_port=587
|
smtp_port=587
|
||||||
smtp_user=sender@example.com
|
smtp_user=sender@example.com
|
||||||
smtp_password=secret
|
smtp_password=dpapi:<base64-blob> ; encrypted
|
||||||
use_tls=true
|
use_tls=true
|
||||||
recipients=admin@example.com
|
recipients=admin@example.com
|
||||||
send_time=18:00
|
send_time=18:00
|
||||||
|
|
||||||
|
[groq]
|
||||||
|
api_key=dpapi:<base64-blob> ; encrypted
|
||||||
|
model=llama-3.3-70b-versatile
|
||||||
|
|
||||||
[crypto]
|
[crypto]
|
||||||
salt=<base64 32-byte salt — auto-generated>
|
salt=<base64 32-byte salt — auto-generated>
|
||||||
```
|
```
|
||||||
@@ -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
|
- DB_CONFIG loaded from config.ini at import; placeholders if file missing
|
||||||
- get_connection() → pool (pool_size=5)
|
- get_connection() → pool (pool_size=5)
|
||||||
- initialize_database() → idempotent DDL + ALTER TABLE migrations
|
- 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
|
- config_exists() → True if host, database, user are set
|
||||||
- reload_db_config() → re-reads config.ini, resets pool
|
- 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
|
### models.py
|
||||||
Each function opens+closes its own connection. All writes call log_action().
|
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
|
### utils/scheduler.py
|
||||||
- Daemon thread; polls every 60 seconds
|
- Daemon thread; polls every 60 seconds
|
||||||
- Sends HTML email once per day when now >= send_time
|
- 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)
|
- last_sent_date in-memory (resets on restart)
|
||||||
- start() / stop() called from app.py on login/logout (admin only)
|
- start() / stop() called from app.py on login/logout (admin only)
|
||||||
|
|
||||||
### views/login_view.py
|
### views/ai_summary_view.py *(NEW)*
|
||||||
- check_login_allowed(username) called before authenticate()
|
AI-powered document analysis panel. Accessible from the sidebar for both admin and user roles.
|
||||||
- Locked: form disabled; 1-second countdown; re-enables when timer reaches 0
|
|
||||||
- Not-yet-locked failed attempt: shows "N attempt(s) remaining before lockout"
|
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
|
### views/user_dashboard_view.py
|
||||||
Key internals:
|
Key internals:
|
||||||
@@ -192,6 +232,15 @@ Key internals:
|
|||||||
- Mousewheel: Enter/Leave scoped; unbind_all in destroy()
|
- Mousewheel: Enter/Leave scoped; unbind_all in destroy()
|
||||||
- Keyboard shortcuts: self.bind() stored in _shortcut_ids; unbound 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
|
- 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
|
### views/admin_shifts_view.py
|
||||||
- _export_pdf(): reportlab A4 document; one section per active shift; user+website tables
|
- _export_pdf(): reportlab A4 document; one section per active shift; user+website tables
|
||||||
@@ -207,11 +256,19 @@ Key internals:
|
|||||||
| Concern | Implementation |
|
| Concern | Implementation |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Password hashing | bcrypt rounds=12; SHA-256 auto-rehashed on next login |
|
| 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 |
|
| Login rate limiting | 5 attempts → 15-min lockout in DB |
|
||||||
| Session timeout | 30-min idle; 1-min warning; any mouse/key event resets |
|
| Session timeout | 30-min idle; 1-min warning; any mouse/key event resets |
|
||||||
| Password policy | 8+ chars, uppercase, digit, special character |
|
| Password policy | 8+ chars, uppercase, digit, special character |
|
||||||
| Self-service change | Requires current password; strength meter; same-as-current guard |
|
| 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 |
|
| user_dashboard_view.py | NOTIFY_MINUTES_BEFORE | 15 |
|
||||||
| admin_dashboard_view.py | REFRESH_INTERVAL_MS | 60,000 |
|
| admin_dashboard_view.py | REFRESH_INTERVAL_MS | 60,000 |
|
||||||
| utils/crypto.py | _ITERATIONS | 100,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.
|
3. bcrypt is slow by design (~200-400ms at rounds=12). Expected behaviour.
|
||||||
|
|
||||||
4. config.ini stores DB password and SMTP password in plaintext.
|
4. config.ini sensitive fields are DPAPI-encrypted (dpapi: prefix).
|
||||||
Website credential passwords are Fernet-encrypted.
|
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
|
5. get_today_checks GROUP BY includes sc.id to prevent row collisions when a user
|
||||||
belongs to multiple shifts sharing the same website.
|
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
|
8. All log messages use ASCII only (hyphens not em-dashes/arrows) to prevent
|
||||||
cp1252 UnicodeEncodeError on Windows consoles.
|
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
|
## 10. Dependencies
|
||||||
@@ -288,9 +355,15 @@ cryptography>=41.0.0
|
|||||||
matplotlib>=3.7.0
|
matplotlib>=3.7.0
|
||||||
plyer>=2.1.0
|
plyer>=2.1.0
|
||||||
reportlab>=4.0.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)
|
1. _boot() → config_exists() → False → SettingsView (locked modal)
|
||||||
2. User enters DB creds → Test Connection → Save & Connect
|
2. User enters DB creds → Test Connection → Save & Connect
|
||||||
3. reload_db_config() → initialize_database() → seeds admin/admin123 if users empty
|
3. reload_db_config() → initialize_database() → seeds admin/admin123 if users empty
|
||||||
4. Login screen shown
|
4. migrate_plaintext_config() → encrypts any plain-text credentials in config.ini
|
||||||
5. ThemeManager(root, initial="light") applied; shell built
|
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.
|
- Python 3.9+ required. 3.12 tested on Windows.
|
||||||
- tkinter bundled on Windows/macOS; Linux: apt install python3-tk
|
- tkinter bundled on Windows/macOS; Linux: apt install python3-tk
|
||||||
- Create DB first: CREATE DATABASE website_checker CHARACTER SET utf8mb4;
|
- Create DB first: CREATE DATABASE website_checker CHARACTER SET utf8mb4;
|
||||||
- MySQL port 3306 must be reachable from client
|
- MySQL port 3306 must be reachable from client
|
||||||
- Default admin: username=admin / password=admin123 — change immediately
|
- 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.
@@ -75,6 +75,12 @@ class App(tk.Tk):
|
|||||||
# Re-open setup so the user can correct the credentials
|
# Re-open setup so the user can correct the credentials
|
||||||
self._show_setup(on_complete=self._init_db)
|
self._show_setup(on_complete=self._init_db)
|
||||||
return
|
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()
|
self._show_login()
|
||||||
|
|
||||||
def _show_login(self):
|
def _show_login(self):
|
||||||
|
|||||||
+13
-3
@@ -2,13 +2,23 @@
|
|||||||
host = 67.217.62.199
|
host = 67.217.62.199
|
||||||
port = 3306
|
port = 3306
|
||||||
database = webchecker
|
database = webchecker
|
||||||
user = webchecker
|
user = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAm1sPKS5A3aczt5vkmqeO+XXJLmKwA1ojVXeBzQZ9QIwAAAAADoAAAAACAAAgAAAA6CLkACCqBm32SUXekn49rHMseysgdxMtdhywY0+zxO4QAAAA6KGNWrsRGwpImEXQZfuQikAAAABHFvkj2Z2choaPIoFwNryk31E6oZfI11uv/Ms5eIrN0uSC9VSdjYC9IU7SKCkK2z7BYQB2Uy9QoXZF6VFDXE/r
|
||||||
password = 7x+MxGmks_3U
|
password = dpapi:AQAAANCMnd8BFdERjHoAwE/Cl+sBAAAAtjFleFYUHkGTELQRhDYe8AAAAAAkAAAAVwBlAGIAQwBoAGUAYwBrAGUAcgAgAGMAbwBuAGYAaQBnAAAAEGYAAAABAAAgAAAAO3bOCycWnlLWFscvK6zmWysxARgAyABh7eIYDzK/KN8AAAAADoAAAAACAAAgAAAAFU2hwhMfmMnWGTZL16cWfpc2rs5/Oy1lq7aEqeC5GcUQAAAAExqVIbNsn/vH99tw8NMfBUAAAAA+pf+tJVrYOMikcjrPcFLRKS+dR6+4OGBj/BiM4IgR07Zbk8GNxddhdb9Hg8/2D51oaywFNV55lDd9aJcrtnHS
|
||||||
|
|
||||||
[crypto]
|
[crypto]
|
||||||
salt = GE453Sa0afGLfzq8EPKdXjvZyAzK499XzWxWq/ZdnFQ=
|
salt = GE453Sa0afGLfzq8EPKdXjvZyAzK499XzWxWq/ZdnFQ=
|
||||||
|
|
||||||
[groq]
|
[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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -42,12 +42,18 @@ APP_TITLE = "Website Checker"
|
|||||||
APP_VERSION = "1.0.0"
|
APP_VERSION = "1.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
# Sensitive fields that are DPAPI-encrypted in config.ini
|
||||||
|
_DB_SENSITIVE = {"user", "password"}
|
||||||
|
|
||||||
|
|
||||||
def load_config() -> dict:
|
def load_config() -> dict:
|
||||||
"""
|
"""
|
||||||
Load DB settings from config.ini.
|
Load DB settings from config.ini.
|
||||||
Returns a dict with keys: host, port, database, user, password.
|
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.
|
Returns empty dict if the file does not exist or is incomplete.
|
||||||
"""
|
"""
|
||||||
|
from utils.config_crypto import decrypt_value
|
||||||
cfg = _cp.ConfigParser()
|
cfg = _cp.ConfigParser()
|
||||||
if not _os.path.exists(CONFIG_FILE):
|
if not _os.path.exists(CONFIG_FILE):
|
||||||
return {}
|
return {}
|
||||||
@@ -55,28 +61,77 @@ def load_config() -> dict:
|
|||||||
if "database" not in cfg:
|
if "database" not in cfg:
|
||||||
return {}
|
return {}
|
||||||
section = cfg["database"]
|
section = cfg["database"]
|
||||||
return {
|
try:
|
||||||
"host": section.get("host", ""),
|
return {
|
||||||
"port": section.getint("port", 3306),
|
"host": section.get("host", ""),
|
||||||
"database": section.get("database", ""),
|
"port": section.getint("port", 3306),
|
||||||
"user": section.get("user", ""),
|
"database": section.get("database", ""),
|
||||||
"password": section.get("password", ""),
|
"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):
|
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()
|
cfg = _cp.ConfigParser()
|
||||||
|
if _os.path.exists(CONFIG_FILE):
|
||||||
|
cfg.read(CONFIG_FILE, encoding="utf-8")
|
||||||
cfg["database"] = {
|
cfg["database"] = {
|
||||||
"host": host,
|
"host": host,
|
||||||
"port": str(port),
|
"port": str(port),
|
||||||
"database": database,
|
"database": database,
|
||||||
"user": user,
|
"user": encrypt_value(user),
|
||||||
"password": password,
|
"password": encrypt_value(password),
|
||||||
}
|
}
|
||||||
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
|
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
|
||||||
cfg.write(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:
|
def config_exists() -> bool:
|
||||||
|
|||||||
@@ -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
@@ -38,6 +38,7 @@ _stop_event = threading.Event()
|
|||||||
# ─── Config helpers ───────────────────────────────────────────────────────────
|
# ─── Config helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def load_email_config() -> dict:
|
def load_email_config() -> dict:
|
||||||
|
from utils.config_crypto import decrypt_value
|
||||||
cfg = configparser.ConfigParser()
|
cfg = configparser.ConfigParser()
|
||||||
if not os.path.exists(CONFIG_FILE):
|
if not os.path.exists(CONFIG_FILE):
|
||||||
return {}
|
return {}
|
||||||
@@ -50,7 +51,7 @@ def load_email_config() -> dict:
|
|||||||
"smtp_host": s.get("smtp_host", ""),
|
"smtp_host": s.get("smtp_host", ""),
|
||||||
"smtp_port": s.getint("smtp_port", fallback=587),
|
"smtp_port": s.getint("smtp_port", fallback=587),
|
||||||
"smtp_user": s.get("smtp_user", ""),
|
"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),
|
"use_tls": s.getboolean("use_tls", fallback=True),
|
||||||
"recipients": [r.strip() for r in s.get("recipients", "").split(",") if r.strip()],
|
"recipients": [r.strip() for r in s.get("recipients", "").split(",") if r.strip()],
|
||||||
"send_time": s.get("send_time", "18:00"),
|
"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,
|
def save_email_config(enabled: bool, smtp_host: str, smtp_port: int,
|
||||||
smtp_user: str, smtp_password: str, use_tls: bool,
|
smtp_user: str, smtp_password: str, use_tls: bool,
|
||||||
recipients: str, send_time: str):
|
recipients: str, send_time: str):
|
||||||
|
from utils.config_crypto import encrypt_value
|
||||||
cfg = configparser.ConfigParser()
|
cfg = configparser.ConfigParser()
|
||||||
cfg.read(CONFIG_FILE, encoding="utf-8")
|
cfg.read(CONFIG_FILE, encoding="utf-8")
|
||||||
cfg["email"] = {
|
cfg["email"] = {
|
||||||
@@ -67,14 +69,14 @@ def save_email_config(enabled: bool, smtp_host: str, smtp_port: int,
|
|||||||
"smtp_host": smtp_host,
|
"smtp_host": smtp_host,
|
||||||
"smtp_port": str(smtp_port),
|
"smtp_port": str(smtp_port),
|
||||||
"smtp_user": smtp_user,
|
"smtp_user": smtp_user,
|
||||||
"smtp_password": smtp_password,
|
"smtp_password": encrypt_value(smtp_password),
|
||||||
"use_tls": str(use_tls).lower(),
|
"use_tls": str(use_tls).lower(),
|
||||||
"recipients": recipients,
|
"recipients": recipients,
|
||||||
"send_time": send_time,
|
"send_time": send_time,
|
||||||
}
|
}
|
||||||
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
|
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
|
||||||
cfg.write(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:
|
def test_smtp_connection(smtp_host, smtp_port, smtp_user, smtp_password, use_tls) -> tuple:
|
||||||
|
|||||||
@@ -288,18 +288,34 @@ class WebsiteDialog(tk.Toplevel):
|
|||||||
# Hide initially; shown when visibility='assigned'
|
# Hide initially; shown when visibility='assigned'
|
||||||
self._user_assign_frame.pack_forget()
|
self._user_assign_frame.pack_forget()
|
||||||
|
|
||||||
# ── Credentials section ───────────────────────────────────────────────
|
# ── Credentials section (collapsed by default) ───────────────────────
|
||||||
ttk.Separator(self.inner, orient="horizontal").pack(
|
ttk.Separator(self.inner, orient="horizontal").pack(
|
||||||
fill="x", padx=24, pady=12)
|
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)
|
# Toggle header row
|
||||||
self.creds_container.pack(fill="x", padx=24, pady=8)
|
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 ───────────────────────────────────────────────────────────
|
# ── Buttons ───────────────────────────────────────────────────────────
|
||||||
ttk.Separator(self.inner, orient="horizontal").pack(
|
ttk.Separator(self.inner, orient="horizontal").pack(
|
||||||
@@ -325,10 +341,13 @@ class WebsiteDialog(tk.Toplevel):
|
|||||||
for uid, var in self._user_vars.items():
|
for uid, var in self._user_vars.items():
|
||||||
var.set(uid in assigned_ids)
|
var.set(uid in assigned_ids)
|
||||||
self._on_visibility_change()
|
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)
|
self._add_cred_row(cred)
|
||||||
else:
|
# Auto-expand credentials section if creds already exist
|
||||||
self._add_cred_row()
|
if existing_creds:
|
||||||
|
self._expand_credentials()
|
||||||
|
# For new websites: credentials section stays collapsed
|
||||||
|
|
||||||
def _on_visibility_change(self):
|
def _on_visibility_change(self):
|
||||||
"""Show or hide the user assignment panel based on visibility selection."""
|
"""Show or hide the user assignment panel based on visibility selection."""
|
||||||
@@ -338,6 +357,27 @@ class WebsiteDialog(tk.Toplevel):
|
|||||||
else:
|
else:
|
||||||
self._user_assign_frame.pack_forget()
|
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):
|
def _add_cred_row(self, cred=None):
|
||||||
frame = ttk.Frame(self.creds_container, style="Surface.TFrame")
|
frame = ttk.Frame(self.creds_container, style="Surface.TFrame")
|
||||||
frame.pack(fill="x", pady=4, ipady=4)
|
frame.pack(fill="x", pady=4, ipady=4)
|
||||||
|
|||||||
@@ -156,11 +156,12 @@ class AiSummaryView(ttk.Frame):
|
|||||||
try:
|
try:
|
||||||
import configparser
|
import configparser
|
||||||
from config import CONFIG_FILE
|
from config import CONFIG_FILE
|
||||||
|
from utils.config_crypto import decrypt_value
|
||||||
cfg = configparser.ConfigParser()
|
cfg = configparser.ConfigParser()
|
||||||
cfg.read(CONFIG_FILE, encoding="utf-8")
|
cfg.read(CONFIG_FILE, encoding="utf-8")
|
||||||
if cfg.has_section(_CFG_SECTION):
|
if cfg.has_section(_CFG_SECTION):
|
||||||
self._api_key_var.set(
|
raw_key = cfg.get(_CFG_SECTION, _CFG_KEY_KEY, fallback="")
|
||||||
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,
|
model = cfg.get(_CFG_SECTION, _CFG_KEY_MODEL,
|
||||||
fallback=GROQ_MODELS[0])
|
fallback=GROQ_MODELS[0])
|
||||||
if model in GROQ_MODELS:
|
if model in GROQ_MODELS:
|
||||||
@@ -169,15 +170,17 @@ class AiSummaryView(ttk.Frame):
|
|||||||
logger.warning(f"Could not load Groq config: {e}")
|
logger.warning(f"Could not load Groq config: {e}")
|
||||||
|
|
||||||
def _save_config(self):
|
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:
|
try:
|
||||||
import configparser
|
import configparser
|
||||||
from config import CONFIG_FILE
|
from config import CONFIG_FILE
|
||||||
|
from utils.config_crypto import encrypt_value
|
||||||
cfg = configparser.ConfigParser()
|
cfg = configparser.ConfigParser()
|
||||||
cfg.read(CONFIG_FILE, encoding="utf-8")
|
cfg.read(CONFIG_FILE, encoding="utf-8")
|
||||||
if not cfg.has_section(_CFG_SECTION):
|
if not cfg.has_section(_CFG_SECTION):
|
||||||
cfg.add_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())
|
cfg.set(_CFG_SECTION, _CFG_KEY_MODEL, self._model_var.get())
|
||||||
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
|
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
|
||||||
cfg.write(fh)
|
cfg.write(fh)
|
||||||
|
|||||||
@@ -362,7 +362,24 @@ class UserDashboardView(ttk.Frame):
|
|||||||
activeforeground=COLOURS["accent"],
|
activeforeground=COLOURS["accent"],
|
||||||
relief="flat", font=FONT_SMALL, cursor="hand2",
|
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||||||
padx=10, pady=4,
|
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 ─────────────────────────────────────────────────────────
|
# ─── Bulk Actions ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -507,11 +524,6 @@ class UserDashboardView(ttk.Frame):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
show_error(f"Could not open URL:\n{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):
|
def _mark_checked(self, site: dict):
|
||||||
try:
|
try:
|
||||||
from models import mark_website_checked
|
from models import mark_website_checked
|
||||||
@@ -647,22 +659,39 @@ class CredentialsPopup(tk.Toplevel):
|
|||||||
label = cred.get("label") or "Default"
|
label = cred.get("label") or "Default"
|
||||||
tk.Label(frame, text=label, font=FONT_BOLD,
|
tk.Label(frame, text=label, font=FONT_BOLD,
|
||||||
bg=COLOURS["surface"], fg=COLOURS["accent"]).grid(
|
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"],
|
tk.Label(frame, text="Username:", bg=COLOURS["surface"],
|
||||||
fg=COLOURS["text_dim"]).grid(
|
fg=COLOURS["text_dim"]).grid(
|
||||||
row=1, column=0, sticky="w", padx=(10, 4))
|
row=1, column=0, sticky="w", padx=(10, 4))
|
||||||
tk.Label(frame, text=cred["username"], bg=COLOURS["surface"],
|
tk.Label(frame, text=cred["username"], bg=COLOURS["surface"],
|
||||||
fg=COLOURS["text"], font=FONT_BOLD).grid(
|
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"],
|
tk.Label(frame, text="Password:", bg=COLOURS["surface"],
|
||||||
fg=COLOURS["text_dim"]).grid(
|
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="••••••••")
|
pw_var = tk.StringVar(value="••••••••")
|
||||||
tk.Label(frame, textvariable=pw_var, bg=COLOURS["surface"],
|
tk.Label(frame, textvariable=pw_var, bg=COLOURS["surface"],
|
||||||
fg=COLOURS["text"], font=FONT_BOLD).grid(
|
fg=COLOURS["text"], font=FONT_BOLD).grid(
|
||||||
row=1, column=3, sticky="w")
|
row=1, column=4, sticky="w", padx=(0, 4))
|
||||||
|
|
||||||
revealed = [False]
|
revealed = [False]
|
||||||
def toggle(c=cred, v=pw_var, r=revealed):
|
def toggle(c=cred, v=pw_var, r=revealed):
|
||||||
@@ -671,7 +700,24 @@ class CredentialsPopup(tk.Toplevel):
|
|||||||
tk.Button(frame, text="👁", command=toggle,
|
tk.Button(frame, text="👁", command=toggle,
|
||||||
bg=COLOURS["surface"], fg=COLOURS["text_dim"],
|
bg=COLOURS["surface"], fg=COLOURS["text_dim"],
|
||||||
relief="flat", cursor="hand2").grid(
|
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)
|
ttk.Button(self, text="Close", command=self.destroy).pack(pady=16)
|
||||||
self._centre()
|
self._centre()
|
||||||
|
|||||||
Reference in New Issue
Block a user