04/26 Enhanced functionalities
This commit is contained in:
+1
-1
@@ -282,6 +282,6 @@ cython_debug/
|
|||||||
marimo/\_static/
|
marimo/\_static/
|
||||||
marimo/\_lsp/
|
marimo/\_lsp/
|
||||||
**marimo**/
|
**marimo**/
|
||||||
README.md
|
# README.md
|
||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
# PassKeeper 🔐
|
||||||
|
|
||||||
|
A self-hosted, zero-knowledge password manager — web app and browser extension. Modelled after LastPass, built on Flask + MySQL + Web Crypto API.
|
||||||
|
|
||||||
|
Your master password and decrypted vault data **never leave your browser**. The server stores only encrypted blobs it cannot read.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Web App
|
||||||
|
- **Zero-knowledge encryption** — AES-256-GCM client-side, 600k-iteration PBKDF2 vault key derivation
|
||||||
|
- **7 item types** — Passwords, Secure Notes, Payment Cards, Bank Accounts, Addresses, Identities, Passkeys
|
||||||
|
- **TOTP/2FA support** — per-site TOTP codes stored inside the encrypted vault blob; live 6-digit display with countdown
|
||||||
|
- **Folder organisation** — create, rename, delete folders; filter vault by folder
|
||||||
|
- **Item sharing** — zero-knowledge ECDH P-256 re-encryption; share with any registered user
|
||||||
|
- **Emergency access** — configurable wait-timer access grant for a trusted contact
|
||||||
|
- **Security dashboard** — weak, reused, and old password detection + **HaveIBeenPwned k-anonymity breach check** (passwords never transmitted)
|
||||||
|
- **Account MFA** — TOTP-based login verification (Google Authenticator / Authy)
|
||||||
|
- **Master password change** — atomic zero-knowledge re-encryption of the entire vault
|
||||||
|
- **Account recovery** — 128-bit recovery code; server never stores it
|
||||||
|
- **Audit log** — server-side trail of all create/edit/delete actions; no sensitive data ever logged
|
||||||
|
- **Encrypted item names** — item names stored as AES-256-GCM ciphertext; server holds only the item type as a label
|
||||||
|
- **Responsive layout** — phone, tablet, laptop, and large desktop breakpoints; collapsible sidebar
|
||||||
|
|
||||||
|
### Browser Extension (Chrome / Edge — Manifest V3)
|
||||||
|
- **Autofill** — detects login forms; injects icon into username and password fields only (scored heuristic, not all text inputs)
|
||||||
|
- **Suggestion dropdown** — anchored below the focused field; filters as you type; keyboard navigation (↑↓ Enter); fills with one click
|
||||||
|
- **Smart domain matching** — matches vault items by domain name, handles bare domains (`github.com`) and subdomains
|
||||||
|
- **Auto-save banner** — prompts to save or update credentials on form submit; duplicate detection; "Never for this site" blocklist
|
||||||
|
- **Inline password generator** — length slider, charset toggles, strength indicator; fully CSPRNG (`crypto.getRandomValues` throughout)
|
||||||
|
- **TOTP live display** — 6-digit code + countdown timer per item in the popup
|
||||||
|
- **SSO bridge** — log in once on the web app; extension picks up the session automatically
|
||||||
|
- **Idle lock** — configurable auto-lock timeout (1 / 5 / 10 / 30 min / Never)
|
||||||
|
- **Security** — decrypted vault data stored in `chrome.storage.session` only (memory-only, never written to disk)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Master Password
|
||||||
|
│
|
||||||
|
├─ PBKDF2(email, 100k iter) ──► authHash ──► POST /api/auth/login
|
||||||
|
│ │
|
||||||
|
│ Argon2id(authHash) stored in DB
|
||||||
|
│
|
||||||
|
└─ PBKDF2(enc_key_salt, 600k iter) ──► vaultKey (stays in browser memory only)
|
||||||
|
│
|
||||||
|
AES-256-GCM encrypt
|
||||||
|
│
|
||||||
|
enc_data + iv ──► POST /api/vault
|
||||||
|
enc_name + iv_name (item name, encrypted separately)
|
||||||
|
```
|
||||||
|
|
||||||
|
The server is blind to all vault content. A database breach exposes only encrypted ciphertext.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|---|---|
|
||||||
|
| Backend | Python 3.12, Flask 3.x |
|
||||||
|
| Database | MySQL 8.x |
|
||||||
|
| Frontend | Vanilla JS, Web Crypto API, Jinja2 |
|
||||||
|
| Auth | Argon2id + PBKDF2 + JWT (HS256) |
|
||||||
|
| Encryption | AES-256-GCM (client-side) |
|
||||||
|
| Extension | Chrome Manifest V3 |
|
||||||
|
| Web server | Nginx + Gunicorn + systemd |
|
||||||
|
| Rate limiting | Flask-Limiter + Redis |
|
||||||
|
| TLS | Let's Encrypt / Certbot |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Python 3.12+
|
||||||
|
- MySQL 8.x
|
||||||
|
- Node.js is **not** required — no build step
|
||||||
|
|
||||||
|
### Development (Windows)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone and set up virtual environment
|
||||||
|
git clone https://github.com/yourname/passkeeper
|
||||||
|
cd passkeeper
|
||||||
|
python -m venv .venv
|
||||||
|
.venv\Scripts\activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 2. Configure environment
|
||||||
|
copy .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
FLASK_ENV=development
|
||||||
|
SECRET_KEY=your-secret-key
|
||||||
|
JWT_SECRET_KEY=your-jwt-secret
|
||||||
|
MYSQL_HOST=localhost
|
||||||
|
MYSQL_USER=passkeeper
|
||||||
|
MYSQL_PASSWORD=yourpassword
|
||||||
|
MYSQL_DB=passkeeper
|
||||||
|
TOTP_ENCRYPTION_KEY=64-char-hex-string # python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 3. Create database
|
||||||
|
mysql -u root -p -e "CREATE DATABASE passkeeper CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||||
|
mysql -u root -p -e "CREATE USER 'passkeeper'@'localhost' IDENTIFIED BY 'yourpassword';"
|
||||||
|
mysql -u root -p -e "GRANT ALL PRIVILEGES ON passkeeper.* TO 'passkeeper'@'localhost'; FLUSH PRIVILEGES;"
|
||||||
|
|
||||||
|
# 4. Create tables
|
||||||
|
python reset_db.py
|
||||||
|
|
||||||
|
# 5. Run
|
||||||
|
python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:5000`.
|
||||||
|
|
||||||
|
### Load the Extension (Chrome)
|
||||||
|
|
||||||
|
1. Open `chrome://extensions/`
|
||||||
|
2. Enable **Developer mode** (top-right toggle)
|
||||||
|
3. Click **Load unpacked** → select the `extension/` folder
|
||||||
|
4. Reload the extension after any JS/CSS changes
|
||||||
|
|
||||||
|
Content script logs appear in the **page's** DevTools console. Background service worker logs are at `chrome://extensions/` → PassKeeper → "Service Worker".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
### 1. Server dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update && sudo apt install python3.12 python3.12-venv mysql-server nginx \
|
||||||
|
certbot python3-certbot-nginx redis-server
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Application setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/passkeeper
|
||||||
|
python3.12 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env # set production values
|
||||||
|
flask db upgrade # run all migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. systemd service
|
||||||
|
|
||||||
|
Copy `scripts/passkeeper.service` to `/etc/systemd/system/passkeeper.service`, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now passkeeper
|
||||||
|
journalctl -xeu passkeeper.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Key flags in the service unit: `--preload` (single app import, lower memory), `WatchdogSec=60s`, `Restart=on-failure`.
|
||||||
|
|
||||||
|
### 4. Nginx
|
||||||
|
|
||||||
|
Copy `scripts/passkeeper-nginx.conf` to `/etc/nginx/sites-available/passkeeper`, update `server_name`, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ln -s /etc/nginx/sites-available/passkeeper /etc/nginx/sites-enabled/
|
||||||
|
sudo certbot --nginx -d yourdomain.com
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
The Nginx config uses an `upstream` block with `proxy_next_upstream` for zero-downtime Gunicorn restarts, dual rate-limit zones (auth endpoints and general API), and `Cache-Control: immutable` for static assets.
|
||||||
|
|
||||||
|
### 5. Schema migrations (future updates)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flask db upgrade
|
||||||
|
sudo systemctl reload passkeeper
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| `SECRET_KEY` | Flask session secret | 64-char random hex |
|
||||||
|
| `JWT_SECRET_KEY` | JWT signing secret | 64-char random hex |
|
||||||
|
| `MYSQL_HOST` | MySQL host | `localhost` |
|
||||||
|
| `MYSQL_USER` | MySQL username | `passkeeper` |
|
||||||
|
| `MYSQL_PASSWORD` | MySQL password | — |
|
||||||
|
| `MYSQL_DB` | Database name | `passkeeper` |
|
||||||
|
| `TOTP_ENCRYPTION_KEY` | Server-side AES key for TOTP secrets | 64-char hex |
|
||||||
|
| `RATELIMIT_STORAGE_URI` | Redis URI for rate limiting | `redis://127.0.0.1:6379/0` |
|
||||||
|
| `CORS_ORIGINS` | Allowed CORS origins | `https://yourdomain.com` |
|
||||||
|
|
||||||
|
Generate secrets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
passkeeper/
|
||||||
|
├── app/ # Flask application
|
||||||
|
│ ├── models/ # SQLAlchemy models
|
||||||
|
│ ├── routes/ # API blueprints (auth, vault, folders, sharing, emergency)
|
||||||
|
│ ├── services/ # Auth service (Argon2id, JWT, TOTP encryption)
|
||||||
|
│ ├── static/js/ # Client-side crypto + vault UI
|
||||||
|
│ └── templates/ # Jinja2 HTML templates
|
||||||
|
├── extension/ # Chrome extension (Manifest V3)
|
||||||
|
│ ├── popup/ # Popup UI (HTML + CSS + JS)
|
||||||
|
│ ├── content/ # Content script (form detection, autofill icon, dropdown)
|
||||||
|
│ ├── shared/ # Shared crypto (vault key, encryptName/decryptName)
|
||||||
|
│ ├── bridge/ # SSO bridge (web app ↔ extension session sync)
|
||||||
|
│ └── background.js # Service worker (badges, message relay, idle lock)
|
||||||
|
├── migrations/ # Alembic migration scripts
|
||||||
|
├── scripts/ # Nginx config, systemd unit, backup scripts
|
||||||
|
├── reset_db.py # Dev-only: drop + recreate all tables
|
||||||
|
├── requirements.txt
|
||||||
|
├── run.py # Development server entry point
|
||||||
|
└── wsgi.py # Gunicorn entry point
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Overview
|
||||||
|
|
||||||
|
All vault and folder endpoints require `Authorization: Bearer <access_token>`.
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | `/api/auth/register` | Create account |
|
||||||
|
| POST | `/api/auth/login` | Authenticate; returns tokens or MFA challenge |
|
||||||
|
| POST | `/api/auth/mfa/verify` | Complete MFA step |
|
||||||
|
| POST | `/api/auth/refresh` | Rotate refresh token |
|
||||||
|
| POST | `/api/auth/logout` | Blacklist both tokens |
|
||||||
|
| GET | `/api/vault` | List all encrypted vault items |
|
||||||
|
| POST | `/api/vault` | Create item (`enc_data`, `iv`, `enc_name`, `iv_name`) |
|
||||||
|
| PUT | `/api/vault/<id>` | Update item |
|
||||||
|
| DELETE | `/api/vault/<id>` | Delete item |
|
||||||
|
| GET | `/api/folders` | List folders |
|
||||||
|
| POST | `/api/sharing` | Share item (ECDH re-encryption) |
|
||||||
|
| POST | `/api/emergency` | Create emergency access grant |
|
||||||
|
| POST | `/api/auth/change-password` | Atomic vault re-encryption on password change |
|
||||||
|
| POST | `/api/auth/recover` | Account recovery (one-time use) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backup
|
||||||
|
|
||||||
|
Automated MySQL backups with 30-day retention:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install cron job
|
||||||
|
sudo cp scripts/passkeeper-logrotate /etc/logrotate.d/passkeeper
|
||||||
|
crontab scripts/backup.cron
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual backup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/backup_db.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
MIT — see `LICENSE`.
|
||||||
@@ -137,3 +137,84 @@ def delete_item(item_id):
|
|||||||
)
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({'message': 'Item deleted'}), 200
|
return jsonify({'message': 'Item deleted'}), 200
|
||||||
|
|
||||||
|
|
||||||
|
# ── Import / Export ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@vault_bp.route('/export', methods=['GET'])
|
||||||
|
@require_jwt
|
||||||
|
def export_items():
|
||||||
|
"""
|
||||||
|
Return all vault items as an encrypted JSON export payload.
|
||||||
|
The client receives raw encrypted blobs and wraps them in a
|
||||||
|
signed JSON envelope — the server never sees plaintext.
|
||||||
|
Each object: { id, name, item_type, folder_id, enc_data, iv, enc_name, iv_name,
|
||||||
|
created_at, updated_at }
|
||||||
|
"""
|
||||||
|
items = VaultItem.query.filter_by(user_id=g.current_user_id).order_by(
|
||||||
|
VaultItem.name.asc()
|
||||||
|
).all()
|
||||||
|
AuditLog.log(
|
||||||
|
user_id=g.current_user_id,
|
||||||
|
action='vault_item.export',
|
||||||
|
resource_type='vault_item',
|
||||||
|
resource_id=None,
|
||||||
|
detail=f'Exported {len(items)} vault item(s)',
|
||||||
|
ip_address=_client_ip(),
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify([item.to_dict() for item in items]), 200
|
||||||
|
|
||||||
|
|
||||||
|
@vault_bp.route('/import', methods=['POST'])
|
||||||
|
@require_jwt
|
||||||
|
def import_items():
|
||||||
|
"""
|
||||||
|
Bulk-import pre-encrypted vault items.
|
||||||
|
Accepts a JSON array of objects matching the POST /api/vault schema.
|
||||||
|
Items are imported as-is — the server stores encrypted blobs only.
|
||||||
|
Duplicate detection is left to the client.
|
||||||
|
Returns { imported: N, skipped: N } where skipped = malformed rows.
|
||||||
|
"""
|
||||||
|
data = request.get_json(silent=True) or []
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return jsonify({'error': 'Request body must be a JSON array'}), 400
|
||||||
|
|
||||||
|
imported = 0
|
||||||
|
skipped = 0
|
||||||
|
for row in data:
|
||||||
|
name = (row.get('name') or '').strip()
|
||||||
|
item_type = row.get('item_type', 'password')
|
||||||
|
enc_data = row.get('enc_data', '')
|
||||||
|
iv = row.get('iv', '')
|
||||||
|
if not name or item_type not in VALID_TYPES or not enc_data or not iv:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
folder_id = row.get('folder_id')
|
||||||
|
enc_name = row.get('enc_name') or None
|
||||||
|
iv_name = row.get('iv_name') or None
|
||||||
|
item = VaultItem(
|
||||||
|
user_id=g.current_user_id,
|
||||||
|
folder_id=folder_id,
|
||||||
|
item_type=item_type,
|
||||||
|
name=name,
|
||||||
|
enc_data=enc_data,
|
||||||
|
iv=iv,
|
||||||
|
enc_name=enc_name,
|
||||||
|
iv_name=iv_name,
|
||||||
|
)
|
||||||
|
db.session.add(item)
|
||||||
|
imported += 1
|
||||||
|
|
||||||
|
if imported:
|
||||||
|
db.session.flush()
|
||||||
|
AuditLog.log(
|
||||||
|
user_id=g.current_user_id,
|
||||||
|
action='vault_item.import',
|
||||||
|
resource_type='vault_item',
|
||||||
|
resource_id=None,
|
||||||
|
detail=f'Imported {imported} item(s), skipped {skipped} malformed row(s)',
|
||||||
|
ip_address=_client_ip(),
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'imported': imported, 'skipped': skipped}), 200
|
||||||
|
|||||||
@@ -2032,3 +2032,94 @@ html.sidebar-open {
|
|||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Import / Export view ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.import-export-section {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-export-heading {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111827;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-export-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-export-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-file-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-file-name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-preview {
|
||||||
|
margin: 14px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #374151;
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-preview-list {
|
||||||
|
margin: 6px 0 0 16px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-note {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-error {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-result {
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-result-ok {
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #15803d;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-result-err {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
}
|
||||||
|
|||||||
+245
-4
@@ -199,7 +199,7 @@ const Vault = (() => {
|
|||||||
|
|
||||||
function switchView(view) {
|
function switchView(view) {
|
||||||
_currentView = view;
|
_currentView = view;
|
||||||
["vault", "security", "sharing", "emergency"].forEach((v) => {
|
["vault", "security", "sharing", "emergency", "import-export"].forEach((v) => {
|
||||||
document
|
document
|
||||||
.getElementById(`view-${v}`)
|
.getElementById(`view-${v}`)
|
||||||
?.classList.toggle("hidden", v !== view);
|
?.classList.toggle("hidden", v !== view);
|
||||||
@@ -218,6 +218,7 @@ const Vault = (() => {
|
|||||||
if (view === "security") renderSecurityDashboard();
|
if (view === "security") renderSecurityDashboard();
|
||||||
if (view === "sharing") loadSharingView();
|
if (view === "sharing") loadSharingView();
|
||||||
if (view === "emergency") loadEmergencyView();
|
if (view === "emergency") loadEmergencyView();
|
||||||
|
if (view === "import-export") loadImportExportView();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Vault render ──────────────────────────────────────────────────────────
|
// ── Vault render ──────────────────────────────────────────────────────────
|
||||||
@@ -552,8 +553,17 @@ const Vault = (() => {
|
|||||||
.flat();
|
.flat();
|
||||||
|
|
||||||
const cutoff = Date.now() - 180 * 86400000;
|
const cutoff = Date.now() - 180 * 86400000;
|
||||||
|
// "Old" only penalises passwords that are ALSO weak or reused.
|
||||||
|
// A strong, unique password that hasn't changed in 200 days is fine —
|
||||||
|
// penalising it discourages good password hygiene.
|
||||||
|
const weakOrReusedIds = new Set([
|
||||||
|
...weak.map((i) => i.id),
|
||||||
|
...reused.map((i) => i.id),
|
||||||
|
]);
|
||||||
const old = pwItems.filter(
|
const old = pwItems.filter(
|
||||||
(i) => new Date(i.created_at).getTime() < cutoff,
|
(i) =>
|
||||||
|
new Date(i.created_at).getTime() < cutoff &&
|
||||||
|
weakOrReusedIds.has(i.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
const total = pwItems.length;
|
const total = pwItems.length;
|
||||||
@@ -580,7 +590,7 @@ const Vault = (() => {
|
|||||||
<div class="sec-stats">
|
<div class="sec-stats">
|
||||||
<div class="sec-stat ${weak.length ? "sec-stat-warn" : "sec-stat-ok"}"><span class="sec-stat-num">${weak.length}</span><span class="sec-stat-label">Weak</span></div>
|
<div class="sec-stat ${weak.length ? "sec-stat-warn" : "sec-stat-ok"}"><span class="sec-stat-num">${weak.length}</span><span class="sec-stat-label">Weak</span></div>
|
||||||
<div class="sec-stat ${reused.length ? "sec-stat-warn" : "sec-stat-ok"}"><span class="sec-stat-num">${reused.length}</span><span class="sec-stat-label">Reused</span></div>
|
<div class="sec-stat ${reused.length ? "sec-stat-warn" : "sec-stat-ok"}"><span class="sec-stat-num">${reused.length}</span><span class="sec-stat-label">Reused</span></div>
|
||||||
<div class="sec-stat ${old.length ? "sec-stat-info" : "sec-stat-ok"}"><span class="sec-stat-num">${old.length}</span><span class="sec-stat-label">Old (>180d)</span></div>
|
<div class="sec-stat ${old.length ? "sec-stat-info" : "sec-stat-ok"}"><span class="sec-stat-num">${old.length}</span><span class="sec-stat-label">Old & Weak</span></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
sectionsEl.innerHTML = "";
|
sectionsEl.innerHTML = "";
|
||||||
@@ -631,7 +641,7 @@ const Vault = (() => {
|
|||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
"Same password used on multiple sites.",
|
"Same password used on multiple sites.",
|
||||||
);
|
);
|
||||||
makeSection("Old Passwords", "🕐", old, "Not changed in over 180 days.");
|
makeSection("Old Passwords", "🕐", old, "Weak or reused passwords not changed in over 180 days.");
|
||||||
|
|
||||||
// ── HaveIBeenPwned breach check ──────────────────────────────────────────
|
// ── HaveIBeenPwned breach check ──────────────────────────────────────────
|
||||||
// Run after the synchronous sections are rendered so the UI is immediately
|
// Run after the synchronous sections are rendered so the UI is immediately
|
||||||
@@ -694,6 +704,234 @@ const Vault = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Import / Export View ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Holds parsed rows from a chosen file, ready for import.
|
||||||
|
let _importRows = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a Chrome/Bitwarden/1Password CSV into a normalised array of plain objects.
|
||||||
|
* Supported column sets:
|
||||||
|
* Chrome: name, url, username, password
|
||||||
|
* Bitwarden: name, login_uri, login_username, login_password, notes, type
|
||||||
|
* 1Password: Title, Url, Username, Password, Notes
|
||||||
|
*/
|
||||||
|
function _parseCsvImport(text) {
|
||||||
|
const lines = text.split(/\r?\n/);
|
||||||
|
if (lines.length < 2) return [];
|
||||||
|
const headers = lines[0].split(',').map((h) => h.trim().replace(/^"|"$/g, '').toLowerCase());
|
||||||
|
|
||||||
|
// Detect format by inspecting header names.
|
||||||
|
const col = (candidates) => {
|
||||||
|
for (const c of candidates) {
|
||||||
|
const idx = headers.indexOf(c);
|
||||||
|
if (idx !== -1) return idx;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
const iName = col(['name', 'title']);
|
||||||
|
const iUrl = col(['url', 'login_uri']);
|
||||||
|
const iUser = col(['username', 'login_username']);
|
||||||
|
const iPass = col(['password', 'login_password']);
|
||||||
|
const iNotes = col(['notes', 'note']);
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
if (!line) continue;
|
||||||
|
// Simple CSV split — handles quoted fields containing commas.
|
||||||
|
const cells = [];
|
||||||
|
let cur = '', inQuote = false;
|
||||||
|
for (const ch of line + ',') {
|
||||||
|
if (ch === '"') { inQuote = !inQuote; }
|
||||||
|
else if (ch === ',' && !inQuote) { cells.push(cur.trim()); cur = ''; }
|
||||||
|
else { cur += ch; }
|
||||||
|
}
|
||||||
|
const get = (idx) => (idx !== -1 && cells[idx] != null ? cells[idx].replace(/^"|"$/g, '') : '');
|
||||||
|
const name = get(iName);
|
||||||
|
const password = get(iPass);
|
||||||
|
if (!name || !password) continue;
|
||||||
|
rows.push({
|
||||||
|
name,
|
||||||
|
url: get(iUrl),
|
||||||
|
username: get(iUser),
|
||||||
|
password,
|
||||||
|
notes: get(iNotes),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _importViewInitialised = false;
|
||||||
|
|
||||||
|
function loadImportExportView() {
|
||||||
|
if (_importViewInitialised) return;
|
||||||
|
_importViewInitialised = true;
|
||||||
|
|
||||||
|
// ── Export ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
document.getElementById('btn-export-json')?.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/vault');
|
||||||
|
if (!res) return;
|
||||||
|
const items = await res.json();
|
||||||
|
const payload = JSON.stringify({ version: 1, exported_at: new Date().toISOString(), items }, null, 2);
|
||||||
|
const blob = new Blob([payload], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `passkeeper-export-${new Date().toISOString().slice(0,10)}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
showToast('Encrypted vault exported.');
|
||||||
|
console.log('[PassKeeper] Vault exported:', items.length, 'items');
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Export failed: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-export-csv')?.addEventListener('click', async () => {
|
||||||
|
const vaultKey = VaultSession.getKey();
|
||||||
|
if (!vaultKey) { showUnlockOverlay(); return; }
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/vault');
|
||||||
|
if (!res) return;
|
||||||
|
const raw = await res.json();
|
||||||
|
const decrypted = await Promise.all(raw.map(async (item) => {
|
||||||
|
try {
|
||||||
|
const plain = await Crypto.decryptItem(vaultKey, item.enc_data, item.iv);
|
||||||
|
let displayName = item.name;
|
||||||
|
if (item.enc_name && item.iv_name) {
|
||||||
|
const n = await Crypto.decryptName(vaultKey, item.enc_name, item.iv_name);
|
||||||
|
if (n) displayName = n;
|
||||||
|
}
|
||||||
|
return { name: displayName, ...plain };
|
||||||
|
} catch { return null; }
|
||||||
|
}));
|
||||||
|
const csvRows = [['name','url','username','password','notes']];
|
||||||
|
decrypted.filter(Boolean).forEach((r) => {
|
||||||
|
if (r.password) {
|
||||||
|
const esc = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`;
|
||||||
|
csvRows.push([r.name, r.url, r.username, r.password, r.notes].map(esc));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const csv = csvRows.map((r) => r.join(',')).join('\n');
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `passkeeper-export-${new Date().toISOString().slice(0,10)}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
showToast('CSV exported — store it securely.');
|
||||||
|
console.log('[PassKeeper] CSV export:', decrypted.filter(Boolean).length, 'items');
|
||||||
|
} catch (err) {
|
||||||
|
showToast('CSV export failed: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Import ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const fileInput = document.getElementById('import-file-input');
|
||||||
|
const fileNameEl = document.getElementById('import-file-name');
|
||||||
|
const previewEl = document.getElementById('import-preview');
|
||||||
|
const confirmBtn = document.getElementById('btn-import-confirm');
|
||||||
|
const resultEl = document.getElementById('import-result');
|
||||||
|
|
||||||
|
fileInput?.addEventListener('change', async () => {
|
||||||
|
const file = fileInput.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
fileNameEl.textContent = file.name;
|
||||||
|
previewEl.classList.add('hidden');
|
||||||
|
resultEl.classList.add('hidden');
|
||||||
|
confirmBtn.disabled = true;
|
||||||
|
_importRows = [];
|
||||||
|
|
||||||
|
const text = await file.text();
|
||||||
|
const isJson = file.name.endsWith('.json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isJson) {
|
||||||
|
// PassKeeper encrypted JSON export.
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
const items = parsed.items || (Array.isArray(parsed) ? parsed : []);
|
||||||
|
if (!items.length) throw new Error('No items found in JSON file.');
|
||||||
|
_importRows = items;
|
||||||
|
previewEl.innerHTML = `<p>Found <strong>${items.length}</strong> encrypted item(s) ready to import.</p>
|
||||||
|
<p class="import-note">These are already encrypted with your vault key — they will be imported as-is.</p>`;
|
||||||
|
} else {
|
||||||
|
// CSV — decrypt and re-encrypt with current vault key.
|
||||||
|
const vaultKey = VaultSession.getKey();
|
||||||
|
if (!vaultKey) { showUnlockOverlay(); return; }
|
||||||
|
const rows = _parseCsvImport(text);
|
||||||
|
if (!rows.length) throw new Error('No valid rows found. Check the CSV format.');
|
||||||
|
_importRows = rows; // stored as plaintext — encrypted on confirm
|
||||||
|
previewEl.innerHTML = `<p>Found <strong>${rows.length}</strong> password(s) to import.</p>
|
||||||
|
<p class="import-note">Preview (first 5):</p>
|
||||||
|
<ul class="import-preview-list">${rows.slice(0, 5).map((r) =>
|
||||||
|
`<li><strong>${escHtml(r.name)}</strong> — ${escHtml(r.username || '(no username)')}</li>`
|
||||||
|
).join('')}</ul>
|
||||||
|
${rows.length > 5 ? `<p class="import-note">…and ${rows.length - 5} more.</p>` : ''}`;
|
||||||
|
}
|
||||||
|
previewEl.classList.remove('hidden');
|
||||||
|
confirmBtn.disabled = false;
|
||||||
|
confirmBtn.dataset.mode = isJson ? 'json' : 'csv';
|
||||||
|
} catch (err) {
|
||||||
|
previewEl.innerHTML = `<p class="import-error">⚠️ ${escHtml(err.message)}</p>`;
|
||||||
|
previewEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
confirmBtn?.addEventListener('click', async () => {
|
||||||
|
const vaultKey = VaultSession.getKey();
|
||||||
|
if (!vaultKey) { showUnlockOverlay(); return; }
|
||||||
|
confirmBtn.disabled = true;
|
||||||
|
confirmBtn.textContent = 'Importing…';
|
||||||
|
resultEl.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
let payload;
|
||||||
|
if (confirmBtn.dataset.mode === 'json') {
|
||||||
|
// Already-encrypted items — send directly.
|
||||||
|
payload = _importRows;
|
||||||
|
} else {
|
||||||
|
// Plaintext CSV rows — encrypt each one now.
|
||||||
|
payload = await Promise.all(_importRows.map(async (row) => {
|
||||||
|
const plain = { url: row.url || '', username: row.username || '', password: row.password, notes: row.notes || '' };
|
||||||
|
const { enc_data, iv } = await Crypto.encryptItem(vaultKey, plain);
|
||||||
|
const { enc_name, iv_name } = await Crypto.encryptName(vaultKey, row.name);
|
||||||
|
return { name: 'password', item_type: 'password', enc_data, iv, enc_name, iv_name };
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await apiFetch('/api/vault/import', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res) return;
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Import failed.');
|
||||||
|
|
||||||
|
resultEl.innerHTML = `✅ Imported <strong>${data.imported}</strong> item(s)${data.skipped ? `, skipped ${data.skipped} malformed row(s)` : ''}.`;
|
||||||
|
resultEl.className = 'import-result import-result-ok';
|
||||||
|
resultEl.classList.remove('hidden');
|
||||||
|
_importRows = [];
|
||||||
|
confirmBtn.textContent = 'Import items';
|
||||||
|
fileInput.value = '';
|
||||||
|
fileNameEl.textContent = 'No file chosen';
|
||||||
|
previewEl.classList.add('hidden');
|
||||||
|
console.log('[PassKeeper] Import complete:', data.imported, 'imported,', data.skipped, 'skipped');
|
||||||
|
await loadVault();
|
||||||
|
} catch (err) {
|
||||||
|
resultEl.innerHTML = `⚠️ ${escHtml(err.message)}`;
|
||||||
|
resultEl.className = 'import-result import-result-err';
|
||||||
|
resultEl.classList.remove('hidden');
|
||||||
|
confirmBtn.disabled = false;
|
||||||
|
confirmBtn.textContent = 'Import items';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Sharing View ──────────────────────────────────────────────────────────
|
// ── Sharing View ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function loadSharingView() {
|
async function loadSharingView() {
|
||||||
@@ -2673,6 +2911,9 @@ const Vault = (() => {
|
|||||||
document
|
document
|
||||||
.getElementById("sidebar-emergency")
|
.getElementById("sidebar-emergency")
|
||||||
?.addEventListener("click", () => switchView("emergency"));
|
?.addEventListener("click", () => switchView("emergency"));
|
||||||
|
document
|
||||||
|
.getElementById("sidebar-import-export")
|
||||||
|
?.addEventListener("click", () => switchView("import-export"));
|
||||||
document
|
document
|
||||||
.getElementById("sidebar-generator")
|
.getElementById("sidebar-generator")
|
||||||
?.addEventListener("click", () => openPasswordGeneratorModal());
|
?.addEventListener("click", () => openPasswordGeneratorModal());
|
||||||
|
|||||||
@@ -122,6 +122,15 @@
|
|||||||
<span class="sidebar-icon">🚨</span>
|
<span class="sidebar-icon">🚨</span>
|
||||||
<span class="sidebar-label">Emergency Access</span>
|
<span class="sidebar-label">Emergency Access</span>
|
||||||
</li>
|
</li>
|
||||||
|
<li
|
||||||
|
class="sidebar-item"
|
||||||
|
id="sidebar-import-export"
|
||||||
|
data-view="import-export"
|
||||||
|
data-tooltip="Import / Export"
|
||||||
|
>
|
||||||
|
<span class="sidebar-icon">↕️</span>
|
||||||
|
<span class="sidebar-label">Import / Export</span>
|
||||||
|
</li>
|
||||||
<li
|
<li
|
||||||
class="sidebar-item"
|
class="sidebar-item"
|
||||||
id="sidebar-generator"
|
id="sidebar-generator"
|
||||||
@@ -264,6 +273,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Import / Export view -->
|
||||||
|
<div id="view-import-export" class="hidden">
|
||||||
|
<header class="vault-header">
|
||||||
|
<h2 class="vault-title">Import / Export</h2>
|
||||||
|
</header>
|
||||||
|
<div class="panel-body">
|
||||||
|
|
||||||
|
<!-- Export -->
|
||||||
|
<section class="import-export-section">
|
||||||
|
<h3 class="import-export-heading">Export Vault</h3>
|
||||||
|
<p class="import-export-desc">
|
||||||
|
Download an encrypted backup of your entire vault. The file contains
|
||||||
|
AES-256-GCM ciphertext — your master password is required to read it.
|
||||||
|
Keep it safe.
|
||||||
|
</p>
|
||||||
|
<div class="import-export-row">
|
||||||
|
<button class="btn-primary" id="btn-export-json">Download encrypted JSON</button>
|
||||||
|
<button class="btn-secondary" id="btn-export-csv">Download CSV (plaintext — handle with care)</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Import -->
|
||||||
|
<section class="import-export-section">
|
||||||
|
<h3 class="import-export-heading">Import</h3>
|
||||||
|
<p class="import-export-desc">
|
||||||
|
Import from a PassKeeper encrypted JSON export, or from a CSV file exported
|
||||||
|
by Chrome, Bitwarden, or 1Password. Duplicates are not checked — review
|
||||||
|
your vault after importing.
|
||||||
|
</p>
|
||||||
|
<div class="import-export-row">
|
||||||
|
<label class="btn-secondary import-file-label" for="import-file-input">
|
||||||
|
Choose file…
|
||||||
|
</label>
|
||||||
|
<input type="file" id="import-file-input" accept=".json,.csv" class="hidden" />
|
||||||
|
<span id="import-file-name" class="import-file-name">No file chosen</span>
|
||||||
|
</div>
|
||||||
|
<div id="import-preview" class="import-preview hidden"></div>
|
||||||
|
<div class="import-export-row">
|
||||||
|
<button class="btn-primary" id="btn-import-confirm" disabled>
|
||||||
|
Import items
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="import-result" class="import-result hidden"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Emergency Access view -->
|
<!-- Emergency Access view -->
|
||||||
<div id="view-emergency" class="hidden">
|
<div id="view-emergency" class="hidden">
|
||||||
<header class="vault-header">
|
<header class="vault-header">
|
||||||
|
|||||||
@@ -182,6 +182,18 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
// Store in local storage so the prompt survives service worker restarts
|
// Store in local storage so the prompt survives service worker restarts
|
||||||
// and is guaranteed to be present when the user next opens the popup.
|
// and is guaranteed to be present when the user next opens the popup.
|
||||||
chrome.storage.local.set({ pending_save: msg.data });
|
chrome.storage.local.set({ pending_save: msg.data });
|
||||||
|
// Show a global "!" badge on the toolbar icon so the user knows to open
|
||||||
|
// the popup even if they never do so otherwise.
|
||||||
|
chrome.action.setBadgeText({ text: "!" });
|
||||||
|
chrome.action.setBadgeBackgroundColor({ color: "#c0392b" });
|
||||||
|
console.log("[PassKeeper] pending_save badge set for", msg.data?.siteName);
|
||||||
|
sendResponse({ ok: true });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the pending-save badge once the popup signals it has handled the prompt.
|
||||||
|
if (msg.type === "CLEAR_SAVE_BADGE") {
|
||||||
|
chrome.action.setBadgeText({ text: "" });
|
||||||
sendResponse({ ok: true });
|
sendResponse({ ok: true });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,5 +50,14 @@
|
|||||||
"16": "icons/icon16.png",
|
"16": "icons/icon16.png",
|
||||||
"48": "icons/icon48.png",
|
"48": "icons/icon48.png",
|
||||||
"128": "icons/icon128.png"
|
"128": "icons/icon128.png"
|
||||||
|
},
|
||||||
|
"commands": {
|
||||||
|
"_execute_action": {
|
||||||
|
"suggested_key": {
|
||||||
|
"default": "Ctrl+Shift+L",
|
||||||
|
"mac": "Command+Shift+L"
|
||||||
|
},
|
||||||
|
"description": "Open PassKeeper"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -714,16 +714,21 @@ async function checkPendingSave() {
|
|||||||
$('save-name').focus();
|
$('save-name').focus();
|
||||||
|
|
||||||
// Wire buttons — use onclick so re-running checkPendingSave never double-binds.
|
// Wire buttons — use onclick so re-running checkPendingSave never double-binds.
|
||||||
$('btn-save-yes').onclick = async () => {
|
// Helper to dismiss the overlay, clear storage, and remove the toolbar badge.
|
||||||
|
function _clearPendingSave() {
|
||||||
$('save-prompt-overlay').classList.add('hidden');
|
$('save-prompt-overlay').classList.add('hidden');
|
||||||
|
chrome.storage.local.remove('pending_save');
|
||||||
|
chrome.runtime.sendMessage({ type: 'CLEAR_SAVE_BADGE' }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
$('btn-save-yes').onclick = async () => {
|
||||||
console.log('[PassKeeper] User saved credential for', pending_save.siteName);
|
console.log('[PassKeeper] User saved credential for', pending_save.siteName);
|
||||||
await saveCredential(pending_save);
|
await saveCredential(pending_save);
|
||||||
await chrome.storage.local.remove('pending_save');
|
_clearPendingSave();
|
||||||
};
|
};
|
||||||
$('btn-save-no').onclick = async () => {
|
$('btn-save-no').onclick = () => {
|
||||||
$('save-prompt-overlay').classList.add('hidden');
|
|
||||||
console.log('[PassKeeper] User dismissed save prompt for', pending_save.siteName);
|
console.log('[PassKeeper] User dismissed save prompt for', pending_save.siteName);
|
||||||
await chrome.storage.local.remove('pending_save');
|
_clearPendingSave();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user