Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3750e99e61 | ||
|
|
2c1f3265b7 | ||
|
|
7ff8e257dd | ||
|
|
8b6ead1f81 | ||
|
|
38d9a9ef9f | ||
|
|
b55a3f01b6 | ||
|
b7bedf085a
|
|||
|
ff82e41012
|
|||
|
45b1fb58e9
|
|||
|
b535a7b967
|
|||
|
abd4a21f8f
|
|||
|
4b2d23e942
|
|||
|
ebe2b695cc
|
@@ -42,25 +42,34 @@ This project uses **standard semver with `v` prefix** (e.g., `v0.0.8`, `v1.2.3`)
|
||||
```
|
||||
If any step fails, fix issues before proceeding.
|
||||
|
||||
6. **Compose a commit message** following this format:
|
||||
6. **Create release zip files** in the `releases/` folder:
|
||||
```
|
||||
mkdir -p releases
|
||||
rm -f releases/plebeian-signer-chrome-v*.zip releases/plebeian-signer-firefox-v*.zip
|
||||
cd dist/chrome && zip -r ../../releases/plebeian-signer-chrome-vX.Y.Z.zip . && cd ../..
|
||||
cd dist/firefox && zip -r ../../releases/plebeian-signer-firefox-vX.Y.Z.zip . && cd ../..
|
||||
```
|
||||
Replace `vX.Y.Z` with the actual version number. Old zip files are deleted to keep only the latest release.
|
||||
|
||||
7. **Compose a commit message** following this format:
|
||||
- First line: 72 chars max, imperative mood summary (e.g., "Release v0.0.8")
|
||||
- Blank line
|
||||
- Bullet points describing each significant change
|
||||
- "Files modified:" section listing affected files
|
||||
- Footer with Claude Code attribution
|
||||
|
||||
7. **Stage all changes** with `git add -A`
|
||||
8. **Stage all changes** with `git add -A`
|
||||
|
||||
8. **Create the commit** with the composed message
|
||||
9. **Create the commit** with the composed message
|
||||
|
||||
9. **Create a git tag** matching the version (e.g., `v0.0.8`)
|
||||
10. **Create a git tag** matching the version (e.g., `v0.0.8`)
|
||||
|
||||
10. **Push to origin** with tags:
|
||||
11. **Push to origin** with tags:
|
||||
```
|
||||
git push origin main --tags
|
||||
```
|
||||
|
||||
11. **Report completion** with the new version and commit hash
|
||||
12. **Report completion** with the new version and commit hash
|
||||
|
||||
## Important:
|
||||
- This is a browser extension with separate Chrome and Firefox builds
|
||||
|
||||
25
CLAUDE.md
@@ -27,7 +27,7 @@ npm run build:chrome && npm run build:firefox
|
||||
|
||||
### Monorepo Structure
|
||||
|
||||
This is an Angular CLI monorepo with three projects:
|
||||
This is an Angular 19 CLI monorepo with three projects:
|
||||
|
||||
- **projects/chrome**: Chrome extension (MV3)
|
||||
- **projects/firefox**: Firefox extension
|
||||
@@ -49,10 +49,20 @@ Message flow: Web App → `window.nostr` → Content Script → Background → C
|
||||
|
||||
- **BrowserSyncHandler**: Encrypted vault data synced across browser instances (or local-only based on user preference)
|
||||
- **BrowserSessionHandler**: Session-scoped decrypted data (unlocked vault state)
|
||||
- **SignerMetaHandler**: Extension metadata (sync flow preference)
|
||||
- **SignerMetaHandler**: Extension metadata (sync flow preference, reckless mode, whitelisted hosts)
|
||||
|
||||
Each browser (Chrome/Firefox) has its own handler implementations in `projects/{browser}/src/app/common/data/`.
|
||||
|
||||
### Vault Encryption (v2)
|
||||
|
||||
The vault uses Argon2id + AES-256-GCM for password-based encryption:
|
||||
- **Key derivation**: Argon2id with 256MB memory, 4 threads, 8 iterations (~3 second derivation)
|
||||
- **Encryption**: AES-256-GCM with random 12-byte IV per encryption
|
||||
- **Salt**: Random 32-byte salt per vault (stored in `BrowserSyncData.salt`)
|
||||
- The derived key is cached in session storage (`BrowserSessionData.vaultKey`) to avoid re-derivation on each operation
|
||||
|
||||
Note: Argon2id runs on main thread via WebAssembly (hash-wasm) because Web Workers cannot load external scripts in browser extensions due to CSP restrictions. A deriving modal provides user feedback during the ~3 second operation.
|
||||
|
||||
### Custom Webpack Build
|
||||
|
||||
Both extensions use `@angular-builders/custom-webpack` to bundle additional entry points beyond the main Angular app:
|
||||
@@ -66,9 +76,18 @@ Both extensions use `@angular-builders/custom-webpack` to bundle additional entr
|
||||
|
||||
The `@common` import alias resolves to `projects/common/src/public-api.ts`. Key exports:
|
||||
- `StorageService`: Central data management with encryption/decryption
|
||||
- `CryptoHelper`, `NostrHelper`: Cryptographic utilities
|
||||
- `CryptoHelper`, `NostrHelper`: Cryptographic utilities (nostr-tools based)
|
||||
- `Argon2Crypto`: Vault encryption with Argon2id key derivation
|
||||
- Shared Angular components and pipes
|
||||
|
||||
### Permission System
|
||||
|
||||
Permissions are stored per identity+host+method combination. The background script checks permissions before executing NIP-07 methods:
|
||||
- `allow`/`deny` policies can be stored for each method
|
||||
- Kind-specific permissions supported for `signEvent`
|
||||
- **Reckless mode**: Auto-approves all actions without prompting (global setting)
|
||||
- **Whitelisted hosts**: Auto-approves all actions from specific hosts
|
||||
|
||||
## Testing Extensions Locally
|
||||
|
||||
**Chrome:**
|
||||
|
||||
112
docs/store/PRIVACY_POLICY.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Plebeian Signer Privacy Policy
|
||||
|
||||
**Last Updated:** December 20, 2025
|
||||
|
||||
## Overview
|
||||
|
||||
Plebeian Signer is a browser extension for managing Nostr identities and signing cryptographic events. This privacy policy explains how we handle your data.
|
||||
|
||||
## Data Collection
|
||||
|
||||
**We do not collect any personal data.**
|
||||
|
||||
Plebeian Signer operates entirely locally within your browser. We do not:
|
||||
- Collect analytics or telemetry
|
||||
- Track your usage or behavior
|
||||
- Send your data to any external servers
|
||||
- Use cookies or tracking technologies
|
||||
- Share any information with third parties
|
||||
|
||||
## Data Storage
|
||||
|
||||
All data is stored locally in your browser using the browser's built-in storage APIs:
|
||||
|
||||
### What We Store Locally
|
||||
|
||||
1. **Encrypted Vault Data**
|
||||
- Your Nostr private keys (encrypted with Argon2id + AES-256-GCM)
|
||||
- Identity nicknames and metadata
|
||||
- Relay configurations
|
||||
- Site permissions
|
||||
|
||||
2. **Session Data**
|
||||
- Temporary decryption keys (cleared when browser closes or vault locks)
|
||||
- Cached profile metadata
|
||||
|
||||
3. **Extension Settings**
|
||||
- Sync preferences
|
||||
- Reckless mode settings
|
||||
- Whitelisted hosts
|
||||
|
||||
### Encryption
|
||||
|
||||
Your private keys are never stored in plaintext. The vault uses:
|
||||
- **Argon2id** for password-based key derivation (256MB memory, 4 threads, 8 iterations)
|
||||
- **AES-256-GCM** for authenticated encryption
|
||||
- **Random salt and IV** generated for each vault
|
||||
|
||||
## Network Communications
|
||||
|
||||
Plebeian Signer makes the following network requests:
|
||||
|
||||
1. **Nostr Relay Connections**
|
||||
- To fetch your profile metadata (kind 0 events)
|
||||
- To fetch relay lists (kind 10002 events)
|
||||
- Only connects to relays you have configured
|
||||
|
||||
2. **NIP-05 Verification**
|
||||
- Fetches `.well-known/nostr.json` from domains in NIP-05 identifiers
|
||||
- Used only to verify identity claims
|
||||
|
||||
**We do not operate any servers.** All relay connections are made directly to the Nostr network.
|
||||
|
||||
## Permissions Explained
|
||||
|
||||
The extension requests these browser permissions:
|
||||
|
||||
- **`storage`**: To save your encrypted vault and settings
|
||||
- **`activeTab`**: To inject the NIP-07 interface into web pages
|
||||
- **`scripting`**: To enable communication between pages and the extension
|
||||
|
||||
## Data Sharing
|
||||
|
||||
We do not share any data with third parties. The extension:
|
||||
- Has no backend servers
|
||||
- Does not use analytics services
|
||||
- Does not include advertising
|
||||
- Does not sell or monetize your data in any way
|
||||
|
||||
## Your Control
|
||||
|
||||
You have full control over your data:
|
||||
- **Export**: You can export your encrypted vault at any time
|
||||
- **Delete**: Use the "Reset Extension" feature to delete all local data
|
||||
- **Lock**: Lock your vault to clear session data immediately
|
||||
|
||||
## Open Source
|
||||
|
||||
Plebeian Signer is open source software. You can audit the code yourself:
|
||||
- Repository: https://git.mleku.dev/mleku/plebeian-signer
|
||||
|
||||
## Children's Privacy
|
||||
|
||||
This extension is not intended for children under 13 years of age. We do not knowingly collect any information from children.
|
||||
|
||||
## Changes to This Policy
|
||||
|
||||
If we make changes to this privacy policy, we will update the "Last Updated" date at the top of this document. Significant changes will be noted in the extension's release notes.
|
||||
|
||||
## Contact
|
||||
|
||||
For privacy-related questions or concerns, please open an issue on our repository:
|
||||
https://git.mleku.dev/mleku/plebeian-signer/issues
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
- All data stays in your browser
|
||||
- Private keys are encrypted with strong cryptography
|
||||
- No analytics, tracking, or data collection
|
||||
- No external servers (except Nostr relays you configure)
|
||||
- Fully open source and auditable
|
||||
293
docs/store/PUBLISHING_GUIDE.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# Extension Store Publishing Guide
|
||||
|
||||
This guide walks you through publishing Plebeian Signer to the Chrome Web Store and Firefox Add-ons.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Assets You Need to Create](#assets-you-need-to-create)
|
||||
2. [Chrome Web Store](#chrome-web-store)
|
||||
3. [Firefox Add-ons](#firefox-add-ons)
|
||||
4. [Ongoing Maintenance](#ongoing-maintenance)
|
||||
|
||||
---
|
||||
|
||||
## Assets You Need to Create
|
||||
|
||||
Before submitting to either store, prepare these assets:
|
||||
|
||||
### Screenshots (Required for both stores)
|
||||
|
||||
Create 3-5 screenshots showing the extension in action:
|
||||
|
||||
1. **Main popup view** - Show the identity card with profile info
|
||||
2. **Permission prompt** - Show a signing request popup
|
||||
3. **Identity management** - Show the identity list/switching
|
||||
4. **Permissions page** - Show the permissions management
|
||||
5. **Settings page** - Show vault settings and options
|
||||
|
||||
**Specifications:**
|
||||
- Chrome: 1280x800 or 640x400 pixels (PNG or JPEG)
|
||||
- Firefox: 1280x800 recommended (PNG or JPEG)
|
||||
|
||||
**Tips:**
|
||||
- Use a clean browser profile
|
||||
- Show realistic data (not "test" or placeholder text)
|
||||
- Capture the full popup or relevant UI area
|
||||
- Consider adding captions/annotations
|
||||
|
||||
### Promotional Images (Chrome only)
|
||||
|
||||
Chrome Web Store uses promotional tiles:
|
||||
|
||||
| Size | Name | Required |
|
||||
|------|------|----------|
|
||||
| 440x280 | Small promo tile | Optional but recommended |
|
||||
| 920x680 | Large promo tile | Optional |
|
||||
| 1400x560 | Marquee promo tile | Optional |
|
||||
|
||||
**Design tips:**
|
||||
- Include the extension icon/logo
|
||||
- Add a tagline like "Secure Nostr Identity Manager"
|
||||
- Use brand colors
|
||||
- Keep text minimal and readable
|
||||
|
||||
### Icon (Already exists)
|
||||
|
||||
You already have icons in the extension:
|
||||
- `icon-48.png` - 48x48
|
||||
- `icon-128.png` - 128x128
|
||||
|
||||
Chrome also wants a 128x128 icon for the store listing (can use the same one).
|
||||
|
||||
### Privacy Policy URL
|
||||
|
||||
You need to host the privacy policy at a public URL. Options:
|
||||
|
||||
1. **GitHub/Gitea Pages** - Host `PRIVACY_POLICY.md` as a webpage
|
||||
2. **Simple webpage** - Create a basic HTML page
|
||||
3. **Gist** - Create a public GitHub gist
|
||||
|
||||
Example URL format: `https://git.mleku.dev/mleku/plebeian-signer/src/branch/main/docs/store/PRIVACY_POLICY.md`
|
||||
|
||||
---
|
||||
|
||||
## Chrome Web Store
|
||||
|
||||
### Step 1: Create Developer Account
|
||||
|
||||
1. Go to https://chrome.google.com/webstore/devconsole
|
||||
2. Sign in with a Google account
|
||||
3. Pay the one-time $5 USD registration fee
|
||||
4. Accept the developer agreement
|
||||
|
||||
### Step 2: Create New Item
|
||||
|
||||
1. Click **"New Item"** button
|
||||
2. Upload `releases/plebeian-signer-chrome-v1.0.5.zip`
|
||||
3. Wait for the upload to process
|
||||
|
||||
### Step 3: Fill Store Listing
|
||||
|
||||
**Product Details:**
|
||||
- **Name:** Plebeian Signer
|
||||
- **Summary:** Copy from `STORE_DESCRIPTION.md` (short description, 132 chars max)
|
||||
- **Description:** Copy from `STORE_DESCRIPTION.md` (full description)
|
||||
- **Category:** Productivity
|
||||
- **Language:** English
|
||||
|
||||
**Graphic Assets:**
|
||||
- Upload your screenshots (at least 1 required, up to 5)
|
||||
- Upload promotional tiles if you have them
|
||||
|
||||
**Additional Fields:**
|
||||
- **Official URL:** `https://git.mleku.dev/mleku/plebeian-signer`
|
||||
- **Support URL:** `https://git.mleku.dev/mleku/plebeian-signer/issues`
|
||||
|
||||
### Step 4: Privacy Tab
|
||||
|
||||
- **Single Purpose:** "Manage Nostr identities and sign cryptographic events for web applications"
|
||||
- **Permission Justifications:**
|
||||
- `storage`: "Store encrypted vault containing user's Nostr identities and extension settings"
|
||||
- `activeTab`: "Inject NIP-07 interface into the active tab when user visits Nostr applications"
|
||||
- `scripting`: "Enable communication between web pages and the extension for signing requests"
|
||||
- **Data Usage:** Check "I do not sell or transfer user data to third parties"
|
||||
- **Privacy Policy URL:** Your hosted privacy policy URL
|
||||
|
||||
### Step 5: Distribution
|
||||
|
||||
- **Visibility:** Public
|
||||
- **Distribution:** All regions (or select specific ones)
|
||||
|
||||
### Step 6: Submit for Review
|
||||
|
||||
1. Review all sections show green checkmarks
|
||||
2. Click **"Submit for Review"**
|
||||
3. Wait 1-3 business days (can take longer for first submission)
|
||||
|
||||
### Chrome Review Notes
|
||||
|
||||
Google may ask about:
|
||||
- Why you need each permission
|
||||
- How you handle user data
|
||||
- Your identity/organization
|
||||
|
||||
Be prepared to respond to reviewer questions via the dashboard.
|
||||
|
||||
---
|
||||
|
||||
## Firefox Add-ons
|
||||
|
||||
### Step 1: Create Developer Account
|
||||
|
||||
1. Go to https://addons.mozilla.org/developers/
|
||||
2. Sign in with a Firefox account (create one if needed)
|
||||
3. No fee required
|
||||
|
||||
### Step 2: Submit New Add-on
|
||||
|
||||
1. Click **"Submit a New Add-on"**
|
||||
2. Select **"On this site"** for hosting
|
||||
3. Upload `releases/plebeian-signer-firefox-v1.0.5.zip`
|
||||
4. Wait for automated validation
|
||||
|
||||
### Step 3: Source Code Submission
|
||||
|
||||
Firefox may request source code because the extension uses bundled/minified JavaScript.
|
||||
|
||||
**If prompted:**
|
||||
1. Create a source code zip (exclude `node_modules`):
|
||||
```bash
|
||||
cd /home/mleku/src/git.mleku.dev/mleku/plebeian-signer
|
||||
zip -r plebeian-signer-source.zip . -x "node_modules/*" -x "dist/*" -x ".git/*"
|
||||
```
|
||||
2. Upload this zip when asked
|
||||
3. Include build instructions (point to CLAUDE.md or add a note):
|
||||
```
|
||||
Build Instructions:
|
||||
1. npm ci
|
||||
2. npm run build:firefox
|
||||
3. Output is in dist/firefox/
|
||||
```
|
||||
|
||||
### Step 4: Fill Listing Details
|
||||
|
||||
**Basic Information:**
|
||||
- **Name:** Plebeian Signer
|
||||
- **Add-on URL:** `plebeian-signer` (creates addons.mozilla.org/addon/plebeian-signer)
|
||||
- **Summary:** Copy short description from `STORE_DESCRIPTION.md`
|
||||
- **Description:** Copy full description (supports some HTML/Markdown)
|
||||
- **Categories:** Privacy & Security
|
||||
|
||||
**Additional Details:**
|
||||
- **Homepage:** `https://git.mleku.dev/mleku/plebeian-signer`
|
||||
- **Support URL:** `https://git.mleku.dev/mleku/plebeian-signer/issues`
|
||||
- **License:** Select appropriate license
|
||||
- **Privacy Policy:** Paste URL to hosted privacy policy
|
||||
|
||||
**Media:**
|
||||
- **Icon:** Already in the extension manifest
|
||||
- **Screenshots:** Upload your screenshots
|
||||
|
||||
### Step 5: Submit for Review
|
||||
|
||||
1. Ensure all required fields are complete
|
||||
2. Click **"Submit Version"**
|
||||
3. Wait for review (usually hours to a few days)
|
||||
|
||||
### Firefox Review Notes
|
||||
|
||||
Firefox reviewers are generally faster but thorough. They may:
|
||||
- Ask for source code (see Step 3)
|
||||
- Question specific code patterns
|
||||
- Request changes for policy compliance
|
||||
|
||||
---
|
||||
|
||||
## Ongoing Maintenance
|
||||
|
||||
### Updating the Extension
|
||||
|
||||
**For new releases:**
|
||||
|
||||
1. Build new version: `/release patch` (or `minor`/`major`)
|
||||
2. Upload the new zip to each store
|
||||
3. Add release notes describing changes
|
||||
4. Submit for review
|
||||
|
||||
**Chrome:**
|
||||
- Go to Developer Dashboard → Your extension → Package → Upload new package
|
||||
|
||||
**Firefox:**
|
||||
- Go to Developer Hub → Your extension → Upload a New Version
|
||||
|
||||
### Responding to Reviews
|
||||
|
||||
Both stores may contact you with:
|
||||
- Policy violation notices
|
||||
- User reports
|
||||
- Review questions
|
||||
|
||||
Monitor your developer email and respond promptly.
|
||||
|
||||
### Version Numbering
|
||||
|
||||
Both stores extract the version from `manifest.json`. Your current setup with `v1.0.5` in `package.json` feeds into the manifests correctly.
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
### Before First Submission
|
||||
|
||||
- [ ] Create 3-5 screenshots
|
||||
- [ ] Create promotional images (Chrome, optional but recommended)
|
||||
- [ ] Host privacy policy at a public URL
|
||||
- [ ] Test the extension zip by loading it unpacked
|
||||
- [ ] Prepare source code zip for Firefox
|
||||
|
||||
### Chrome Web Store
|
||||
|
||||
- [ ] Register developer account ($5)
|
||||
- [ ] Upload extension zip
|
||||
- [ ] Fill all required listing fields
|
||||
- [ ] Add screenshots
|
||||
- [ ] Add privacy policy URL
|
||||
- [ ] Justify all permissions
|
||||
- [ ] Submit for review
|
||||
|
||||
### Firefox Add-ons
|
||||
|
||||
- [ ] Register developer account (free)
|
||||
- [ ] Upload extension zip
|
||||
- [ ] Upload source code if requested
|
||||
- [ ] Fill all required listing fields
|
||||
- [ ] Add screenshots
|
||||
- [ ] Add privacy policy URL
|
||||
- [ ] Submit for review
|
||||
|
||||
---
|
||||
|
||||
## Helpful Links
|
||||
|
||||
- Chrome Developer Dashboard: https://chrome.google.com/webstore/devconsole
|
||||
- Chrome Publishing Docs: https://developer.chrome.com/docs/webstore/publish/
|
||||
- Firefox Developer Hub: https://addons.mozilla.org/developers/
|
||||
- Firefox Extension Workshop: https://extensionworkshop.com/documentation/publish/
|
||||
|
||||
---
|
||||
|
||||
## Estimated Timeline
|
||||
|
||||
| Task | Time |
|
||||
|------|------|
|
||||
| Create screenshots | 30 min - 1 hour |
|
||||
| Create promotional images | 1-2 hours (optional) |
|
||||
| Host privacy policy | 15 min |
|
||||
| Chrome submission | 30 min |
|
||||
| Chrome review | 1-3 business days |
|
||||
| Firefox submission | 30 min |
|
||||
| Firefox review | Hours to 2 days |
|
||||
|
||||
**Total:** You can have both submissions done in an afternoon, with approvals coming within a week.
|
||||
88
docs/store/STORE_DESCRIPTION.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Plebeian Signer - Store Description
|
||||
|
||||
Use this content for Chrome Web Store and Firefox Add-ons listings.
|
||||
|
||||
---
|
||||
|
||||
## Short Description (132 characters max for Chrome)
|
||||
|
||||
Secure Nostr identity manager. Sign events without exposing private keys. Multi-identity support with NIP-07 compatibility.
|
||||
|
||||
---
|
||||
|
||||
## Full Description
|
||||
|
||||
**Plebeian Signer** is a secure browser extension for managing your Nostr identities and signing events without exposing your private keys to web applications.
|
||||
|
||||
### Key Features
|
||||
|
||||
**Multi-Identity Management**
|
||||
- Create and manage multiple Nostr identities from a single extension
|
||||
- Easily switch between identities with one click
|
||||
- Import existing keys or generate new ones
|
||||
|
||||
**Bank-Grade Security**
|
||||
- Private keys never leave the extension
|
||||
- Vault encrypted with Argon2id + AES-256-GCM (the same algorithms used by password managers)
|
||||
- Automatic vault locking for protection
|
||||
|
||||
**NIP-07 Compatible**
|
||||
- Works with all Nostr web applications that support NIP-07
|
||||
- Supports NIP-04 and NIP-44 encryption/decryption
|
||||
- Relay configuration per identity
|
||||
|
||||
**Permission Control**
|
||||
- Fine-grained permission management per application
|
||||
- Approve or deny signing requests on a per-site basis
|
||||
- Optional "Reckless Mode" for trusted applications
|
||||
- Whitelist trusted hosts for automatic approval
|
||||
|
||||
**User-Friendly Interface**
|
||||
- Clean, intuitive design
|
||||
- Profile metadata display with avatar and banner
|
||||
- NIP-05 verification support
|
||||
- Bookmark your favorite Nostr apps
|
||||
|
||||
### How It Works
|
||||
|
||||
1. Create a password-protected vault
|
||||
2. Add your Nostr identities (import existing or generate new)
|
||||
3. Visit any NIP-07 compatible Nostr application
|
||||
4. Approve signing requests through the extension popup
|
||||
|
||||
### Privacy First
|
||||
|
||||
Plebeian Signer is open source and respects your privacy:
|
||||
- No telemetry or analytics
|
||||
- No external servers (except for profile metadata from Nostr relays)
|
||||
- All cryptographic operations happen locally in your browser
|
||||
- Your private keys are encrypted and never transmitted
|
||||
|
||||
### Supported NIPs
|
||||
|
||||
- NIP-07: Browser Extension for Nostr
|
||||
- NIP-04: Encrypted Direct Messages
|
||||
- NIP-44: Versioned Encryption
|
||||
|
||||
### Links
|
||||
|
||||
- Source Code: https://git.mleku.dev/mleku/plebeian-signer
|
||||
- Report Issues: https://git.mleku.dev/mleku/plebeian-signer/issues
|
||||
|
||||
---
|
||||
|
||||
## Category Suggestions
|
||||
|
||||
**Chrome Web Store:**
|
||||
- Primary: Productivity
|
||||
- Secondary: Developer Tools
|
||||
|
||||
**Firefox Add-ons:**
|
||||
- Primary: Privacy & Security
|
||||
- Secondary: Other
|
||||
|
||||
---
|
||||
|
||||
## Tags/Keywords
|
||||
|
||||
nostr, nip-07, signing, identity, privacy, encryption, decentralized, keys, wallet, security
|
||||
129
docs/store/publishing.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Publishing Checklist
|
||||
|
||||
Developer accounts are set up. This document covers the remaining steps.
|
||||
|
||||
## Privacy Policy URL
|
||||
|
||||
```
|
||||
https://git.mleku.dev/mleku/plebeian-signer/src/branch/main/docs/store/PRIVACY_POLICY.md
|
||||
```
|
||||
|
||||
## Screenshots Needed
|
||||
|
||||
Take 3-5 screenshots (1280x800 or 640x400 PNG/JPEG):
|
||||
|
||||
1. **Identity view** - Main popup showing profile card with avatar/banner
|
||||
2. **Permission prompt** - A signing request popup from a Nostr app
|
||||
3. **Identity list** - Multiple identities with switching UI
|
||||
4. **Permissions page** - Managing site permissions
|
||||
5. **Settings** - Vault/reckless mode settings
|
||||
|
||||
**Tips:**
|
||||
- Load the extension in a clean browser profile
|
||||
- Use real-looking test data, not "test123"
|
||||
- Crop to show just the popup/relevant UI
|
||||
|
||||
---
|
||||
|
||||
## Chrome Web Store Submission
|
||||
|
||||
1. Go to https://chrome.google.com/webstore/devconsole
|
||||
2. Click **"New Item"**
|
||||
3. Upload: `releases/plebeian-signer-chrome-v1.0.5.zip`
|
||||
|
||||
### Store Listing Tab
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Name | Plebeian Signer |
|
||||
| Summary | Secure Nostr identity manager. Sign events without exposing private keys. Multi-identity support with NIP-07 compatibility. |
|
||||
| Description | Copy from `docs/store/STORE_DESCRIPTION.md` (full description section) |
|
||||
| Category | Productivity |
|
||||
| Language | English |
|
||||
|
||||
Upload your screenshots.
|
||||
|
||||
### Privacy Tab
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Single Purpose | Manage Nostr identities and sign cryptographic events for web applications |
|
||||
| Privacy Policy URL | `https://git.mleku.dev/mleku/plebeian-signer/src/branch/main/docs/store/PRIVACY_POLICY.md` |
|
||||
|
||||
**Permission Justifications:**
|
||||
|
||||
| Permission | Justification |
|
||||
|------------|---------------|
|
||||
| storage | Store encrypted vault containing user's Nostr identities and extension settings |
|
||||
| activeTab | Inject NIP-07 interface into the active tab when user visits Nostr applications |
|
||||
| scripting | Enable communication between web pages and the extension for signing requests |
|
||||
|
||||
Check: "I do not sell or transfer user data to third parties"
|
||||
|
||||
### Distribution Tab
|
||||
|
||||
- Visibility: Public
|
||||
- Regions: All
|
||||
|
||||
Click **"Submit for Review"**
|
||||
|
||||
---
|
||||
|
||||
## Firefox Add-ons Submission
|
||||
|
||||
1. Go to https://addons.mozilla.org/developers/
|
||||
2. Click **"Submit a New Add-on"**
|
||||
3. Select **"On this site"**
|
||||
4. Upload: `releases/plebeian-signer-firefox-v1.0.5.zip`
|
||||
|
||||
### If Asked for Source Code
|
||||
|
||||
Run this to create source zip:
|
||||
```bash
|
||||
cd /home/mleku/src/git.mleku.dev/mleku/plebeian-signer
|
||||
zip -r plebeian-signer-source.zip . -x "node_modules/*" -x "dist/*" -x ".git/*" -x "releases/*"
|
||||
```
|
||||
|
||||
Build instructions to provide:
|
||||
```
|
||||
1. npm ci
|
||||
2. npm run build:firefox
|
||||
3. Output is in dist/firefox/
|
||||
```
|
||||
|
||||
### Listing Details
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Name | Plebeian Signer |
|
||||
| Add-on URL | plebeian-signer |
|
||||
| Summary | Secure Nostr identity manager. Sign events without exposing private keys. Multi-identity support with NIP-07 compatibility. |
|
||||
| Description | Copy from `docs/store/STORE_DESCRIPTION.md` |
|
||||
| Categories | Privacy & Security |
|
||||
| Homepage | `https://git.mleku.dev/mleku/plebeian-signer` |
|
||||
| Support URL | `https://git.mleku.dev/mleku/plebeian-signer/issues` |
|
||||
| Privacy Policy | `https://git.mleku.dev/mleku/plebeian-signer/src/branch/main/docs/store/PRIVACY_POLICY.md` |
|
||||
|
||||
Upload your screenshots.
|
||||
|
||||
Click **"Submit Version"**
|
||||
|
||||
---
|
||||
|
||||
## After Submission
|
||||
|
||||
- **Chrome:** 1-3 business days review
|
||||
- **Firefox:** Hours to 2 days review
|
||||
|
||||
Check your email for reviewer questions. Both dashboards show review status.
|
||||
|
||||
---
|
||||
|
||||
## Updating Later
|
||||
|
||||
When you release a new version:
|
||||
|
||||
1. Run `/release patch` (or minor/major)
|
||||
2. Chrome: Dashboard → Your extension → Package → Upload new package
|
||||
3. Firefox: Developer Hub → Your extension → Upload a New Version
|
||||
4. Add release notes, submit for review
|
||||
15
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "plebian-signer",
|
||||
"version": "0.0.4",
|
||||
"name": "plebeian-signer",
|
||||
"version": "v0.0.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "plebian-signer",
|
||||
"version": "0.0.4",
|
||||
"name": "plebeian-signer",
|
||||
"version": "v0.0.9",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^19.0.0",
|
||||
"@angular/common": "^19.0.0",
|
||||
@@ -21,6 +21,7 @@
|
||||
"bootstrap": "^5.3.3",
|
||||
"bootstrap-icons": "^1.11.3",
|
||||
"buffer": "^6.0.3",
|
||||
"hash-wasm": "^4.11.0",
|
||||
"nostr-tools": "^2.10.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
@@ -12320,6 +12321,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hash-wasm": {
|
||||
"version": "4.11.0",
|
||||
"resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.11.0.tgz",
|
||||
"integrity": "sha512-HVusNXlVqHe0fzIzdQOGolnFN6mX/fqcrSAOcTBXdvzrXVHwTz11vXeKRmkR5gTuwVpvHZEIyKoePDvuAR+XwQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
|
||||
12
package.json
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "plebeian-signer",
|
||||
"version": "v0.0.9",
|
||||
"version": "v1.0.8",
|
||||
"custom": {
|
||||
"chrome": {
|
||||
"version": "v0.0.9"
|
||||
"version": "v1.0.8"
|
||||
},
|
||||
"firefox": {
|
||||
"version": "v0.0.9"
|
||||
"version": "v1.0.8"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@@ -15,10 +15,11 @@
|
||||
"clean:firefox": "rimraf dist/firefox",
|
||||
"start:chrome": "ng serve chrome",
|
||||
"start:firefox": "ng serve firefox",
|
||||
"fetch-kinds": "node scripts/fetch-kinds.js",
|
||||
"prepare:chrome": "./chrome_prepare_manifest.sh",
|
||||
"prepare:firefox": "./firefox_prepare_manifest.sh",
|
||||
"build:chrome": "npm run prepare:chrome && ng build chrome",
|
||||
"build:firefox": "npm run prepare:firefox && ng build firefox",
|
||||
"build:chrome": "npm run fetch-kinds && npm run prepare:chrome && ng build chrome",
|
||||
"build:firefox": "npm run fetch-kinds && npm run prepare:firefox && ng build firefox",
|
||||
"watch:chrome": "npm run prepare:chrome && ng build chrome --watch --configuration development",
|
||||
"watch:firefox": "npm run prepare:firefox && ng build firefox --watch --configuration development",
|
||||
"test": "ng test",
|
||||
@@ -40,6 +41,7 @@
|
||||
"bootstrap": "^5.3.3",
|
||||
"bootstrap-icons": "^1.11.3",
|
||||
"buffer": "^6.0.3",
|
||||
"hash-wasm": "^4.11.0",
|
||||
"nostr-tools": "^2.10.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
|
||||
3
projects/chrome/public/edit.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.06 9L15 9.94L5.92 19H5V18.08L14.06 9ZM17.66 3C17.41 3 17.15 3.1 16.96 3.29L15.13 5.12L18.88 8.87L20.71 7.04C21.1 6.65 21.1 6 20.71 5.63L18.37 3.29C18.17 3.09 17.92 3 17.66 3ZM14.06 6.19L3 17.25V21H6.75L17.81 9.94L14.06 6.19Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 364 B |
@@ -2,13 +2,16 @@
|
||||
"manifest_version": 3,
|
||||
"name": "Plebeian Signer - Nostr Identity Manager & Signer",
|
||||
"description": "Manage and switch between multiple identities while interacting with Nostr apps",
|
||||
"version": "0.0.9",
|
||||
"version": "1.0.8",
|
||||
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
|
||||
"options_page": "options.html",
|
||||
"permissions": [
|
||||
"windows",
|
||||
"storage"
|
||||
],
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "index.html",
|
||||
"default_icon": {
|
||||
|
||||
|
Before Width: | Height: | Size: 983 B After Width: | Height: | Size: 983 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -226,7 +226,7 @@
|
||||
<!------------->
|
||||
<div class="sam-footer-grid-2">
|
||||
<div class="btn-group">
|
||||
<button id="rejectButton" type="button" class="btn btn-secondary">
|
||||
<button id="rejectOnceButton" type="button" class="btn btn-secondary">
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
@@ -239,16 +239,16 @@
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<button id="rejectJustOnceButton" class="dropdown-item">
|
||||
just once
|
||||
<button id="rejectAlwaysButton" class="dropdown-item">
|
||||
Reject Always
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button id="approveButton" type="button" class="btn btn-primary">
|
||||
Approve
|
||||
<button id="approveAlwaysButton" type="button" class="btn btn-primary">
|
||||
Approve Always
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -260,8 +260,8 @@
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<button id="approveJustOnceButton" class="dropdown-item" href="#">
|
||||
just once
|
||||
<button id="approveOnceButton" class="dropdown-item">
|
||||
Approve Once
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -9,6 +9,9 @@ import { IdentitiesComponent } from './components/home/identities/identities.com
|
||||
import { IdentityComponent } from './components/home/identity/identity.component';
|
||||
import { InfoComponent } from './components/home/info/info.component';
|
||||
import { SettingsComponent } from './components/home/settings/settings.component';
|
||||
import { LogsComponent } from './components/home/logs/logs.component';
|
||||
import { BookmarksComponent } from './components/home/bookmarks/bookmarks.component';
|
||||
import { WalletComponent } from './components/home/wallet/wallet.component';
|
||||
import { NewIdentityComponent } from './components/new-identity/new-identity.component';
|
||||
import { EditIdentityComponent } from './components/edit-identity/edit-identity.component';
|
||||
import { HomeComponent as EditIdentityHomeComponent } from './components/edit-identity/home/home.component';
|
||||
@@ -17,6 +20,7 @@ import { PermissionsComponent as EditIdentityPermissionsComponent } from './comp
|
||||
import { RelaysComponent as EditIdentityRelaysComponent } from './components/edit-identity/relays/relays.component';
|
||||
import { VaultImportComponent } from './components/vault-import/vault-import.component';
|
||||
import { WhitelistedAppsComponent } from './components/whitelisted-apps/whitelisted-apps.component';
|
||||
import { ProfileEditComponent } from './components/profile-edit/profile-edit.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
@@ -65,6 +69,18 @@ export const routes: Routes = [
|
||||
path: 'settings',
|
||||
component: SettingsComponent,
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
component: LogsComponent,
|
||||
},
|
||||
{
|
||||
path: 'bookmarks',
|
||||
component: BookmarksComponent,
|
||||
},
|
||||
{
|
||||
path: 'wallet',
|
||||
component: WalletComponent,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -75,6 +91,10 @@ export const routes: Routes = [
|
||||
path: 'whitelisted-apps',
|
||||
component: WhitelistedAppsComponent,
|
||||
},
|
||||
{
|
||||
path: 'profile-edit',
|
||||
component: ProfileEditComponent,
|
||||
},
|
||||
{
|
||||
path: 'edit-identity/:id',
|
||||
component: EditIdentityComponent,
|
||||
|
||||
@@ -7,7 +7,12 @@
|
||||
<span class="text-muted" style="font-size: 12px">
|
||||
Nothing configured so far.
|
||||
</span>
|
||||
} @for(hostPermissions of hostsPermissions; track hostPermissions) {
|
||||
} @else {
|
||||
<button class="btn btn-danger btn-sm remove-all-btn" (click)="onClickRemoveAllPermissions()">
|
||||
Remove All Permissions
|
||||
</button>
|
||||
}
|
||||
@for(hostPermissions of hostsPermissions; track hostPermissions) {
|
||||
<div class="permissions-card">
|
||||
<span style="margin-bottom: 4px; font-weight: 500">
|
||||
{{ hostPermissions.host }}
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.remove-all-btn {
|
||||
margin-bottom: var(--size);
|
||||
}
|
||||
|
||||
.permissions-card {
|
||||
background: var(--background-light);
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -41,6 +41,14 @@ export class PermissionsComponent extends NavComponent implements OnInit {
|
||||
this.#buildHostsPermissions(this.identity?.id);
|
||||
}
|
||||
|
||||
async onClickRemoveAllPermissions() {
|
||||
const allPermissions = this.hostsPermissions.flatMap(hp => hp.permissions);
|
||||
for (const permission of allPermissions) {
|
||||
await this.#storage.deletePermission(permission.id);
|
||||
}
|
||||
this.#buildHostsPermissions(this.identity?.id);
|
||||
}
|
||||
|
||||
#initialize(identityId: string) {
|
||||
this.identity = this.#storage
|
||||
.getBrowserSessionHandler()
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</div>
|
||||
|
||||
<div class="info-banner">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<span class="emoji">💡</span>
|
||||
<span>These relays are fetched from your NIP-65 relay list (kind 10002). To update your relay list, use a Nostr client that supports NIP-65.</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
|
||||
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<span>Bookmarks</span>
|
||||
<button class="add-btn" title="Bookmark This Page" (click)="onBookmarkThisPage()">
|
||||
<span class="emoji">➕</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bookmarks-container">
|
||||
@if (isLoading) {
|
||||
<div class="loading-state">Loading...</div>
|
||||
} @else if (bookmarks.length === 0) {
|
||||
<div class="empty-state">
|
||||
<span class="sam-text-muted">
|
||||
No bookmarks yet. Click "Bookmark This Page" to add the current page.
|
||||
</span>
|
||||
</div>
|
||||
} @else {
|
||||
@for (bookmark of bookmarks; track bookmark.id) {
|
||||
<div class="bookmark-item" (click)="openBookmark(bookmark)">
|
||||
<div class="bookmark-info">
|
||||
<span class="bookmark-title">{{ bookmark.title }}</span>
|
||||
<span class="bookmark-url">{{ getDomain(bookmark.url) }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="remove-btn"
|
||||
title="Remove bookmark"
|
||||
(click)="onRemoveBookmark(bookmark); $event.stopPropagation()"
|
||||
>
|
||||
<span class="emoji">✕</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,112 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: var(--size);
|
||||
padding-bottom: var(--size);
|
||||
overflow: hidden;
|
||||
|
||||
> *:not(.sam-text-header) {
|
||||
margin-left: var(--size);
|
||||
margin-right: var(--size);
|
||||
}
|
||||
|
||||
.sam-text-header {
|
||||
margin-bottom: var(--size);
|
||||
flex-shrink: 0;
|
||||
|
||||
.add-btn {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background-light);
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bookmarks-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.empty-state,
|
||||
.loading-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.bookmark-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size-h);
|
||||
padding: var(--size-h) var(--size);
|
||||
margin-bottom: var(--size-hh);
|
||||
background: var(--background-light);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--background-light-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.bookmark-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.bookmark-title {
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bookmark-url {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
all: unset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
color: var(--muted-foreground);
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--destructive);
|
||||
color: var(--destructive-foreground);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Bookmark, LoggerService, SignerMetaData, StorageService } from '@common';
|
||||
import { ChromeMetaHandler } from '../../../common/data/chrome-meta-handler';
|
||||
|
||||
@Component({
|
||||
selector: 'app-bookmarks',
|
||||
templateUrl: './bookmarks.component.html',
|
||||
styleUrl: './bookmarks.component.scss',
|
||||
imports: [],
|
||||
})
|
||||
export class BookmarksComponent implements OnInit {
|
||||
readonly #logger = inject(LoggerService);
|
||||
readonly #metaHandler = new ChromeMetaHandler();
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
|
||||
bookmarks: Bookmark[] = [];
|
||||
isLoading = true;
|
||||
|
||||
async ngOnInit() {
|
||||
await this.loadBookmarks();
|
||||
}
|
||||
|
||||
async loadBookmarks() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const metaData = await this.#metaHandler.loadFullData() as SignerMetaData;
|
||||
this.#metaHandler.setFullData(metaData);
|
||||
this.bookmarks = this.#metaHandler.getBookmarks();
|
||||
} catch (error) {
|
||||
console.error('Failed to load bookmarks:', error);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async onBookmarkThisPage() {
|
||||
try {
|
||||
// Get the current tab URL and title
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tab?.url || !tab?.title) {
|
||||
console.error('Could not get current tab info');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already bookmarked
|
||||
if (this.bookmarks.some(b => b.url === tab.url)) {
|
||||
console.log('Page already bookmarked');
|
||||
return;
|
||||
}
|
||||
|
||||
const newBookmark: Bookmark = {
|
||||
id: crypto.randomUUID(),
|
||||
url: tab.url,
|
||||
title: tab.title,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
this.bookmarks = [newBookmark, ...this.bookmarks];
|
||||
await this.saveBookmarks();
|
||||
this.#logger.logBookmarkAdded(newBookmark.url, newBookmark.title);
|
||||
} catch (error) {
|
||||
console.error('Failed to bookmark page:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async onRemoveBookmark(bookmark: Bookmark) {
|
||||
this.bookmarks = this.bookmarks.filter(b => b.id !== bookmark.id);
|
||||
await this.saveBookmarks();
|
||||
this.#logger.logBookmarkRemoved(bookmark.url, bookmark.title);
|
||||
}
|
||||
|
||||
async saveBookmarks() {
|
||||
try {
|
||||
await this.#metaHandler.setBookmarks(this.bookmarks);
|
||||
} catch (error) {
|
||||
console.error('Failed to save bookmarks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
openBookmark(bookmark: Bookmark) {
|
||||
chrome.tabs.create({ url: bookmark.url });
|
||||
}
|
||||
|
||||
getDomain(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.#storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
}
|
||||
@@ -3,34 +3,23 @@
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<a
|
||||
class="tab"
|
||||
routerLink="/home/identity"
|
||||
routerLinkActive="active"
|
||||
title="Your selected identity"
|
||||
>
|
||||
<i class="bi bi-person-circle"></i>
|
||||
<a class="tab" routerLink="/home/identity" routerLinkActive="active" title="You">
|
||||
<span class="emoji">👤</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
class="tab"
|
||||
routerLink="/home/identities"
|
||||
routerLinkActive="active"
|
||||
title="Identities"
|
||||
>
|
||||
<i class="bi bi-people-fill"></i>
|
||||
<a class="tab" routerLink="/home/identities" routerLinkActive="active" title="Identities">
|
||||
<span class="emoji">👥</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
class="tab"
|
||||
routerLink="/home/settings"
|
||||
routerLinkActive="active"
|
||||
title="Settings"
|
||||
>
|
||||
<i class="bi bi-gear"></i>
|
||||
<a class="tab" routerLink="/home/wallet" routerLinkActive="active" title="Wallet">
|
||||
<span class="emoji">💰</span>
|
||||
</a>
|
||||
|
||||
<a class="tab" routerLink="/home/info" routerLinkActive="active" title="Info">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<a class="tab" routerLink="/home/bookmarks" routerLinkActive="active" title="Bookmarks">
|
||||
<span class="emoji">🔖</span>
|
||||
</a>
|
||||
|
||||
<a class="tab" routerLink="/home/settings" routerLinkActive="active" title="Settings">
|
||||
<span class="emoji">⚙️</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -4,17 +4,17 @@
|
||||
flex-direction: column;
|
||||
|
||||
.tab-content {
|
||||
height: calc(100% - 60px);
|
||||
height: calc(100% - 48px);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
height: 60px;
|
||||
min-height: 60px;
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
background: var(--background-light);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
a {
|
||||
a, button {
|
||||
all: unset;
|
||||
}
|
||||
|
||||
@@ -23,14 +23,17 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
|
||||
color: var(--muted-foreground);
|
||||
border-top: 3px solid transparent;
|
||||
border-top: 2px solid transparent;
|
||||
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
.emoji {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--background-light-hover);
|
||||
color: var(--foreground);
|
||||
@@ -38,7 +41,7 @@
|
||||
|
||||
&.active {
|
||||
color: var(--foreground);
|
||||
border-top: 3px solid var(--primary);
|
||||
border-top: 2px solid var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
|
||||
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
|
||||
<div class="custom-header" style="position: sticky; top: 0">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<span class="text">Identities</span>
|
||||
|
||||
<button class="button btn btn-primary btn-sm" (click)="onClickNewIdentity()">
|
||||
<div class="sam-flex-row gap-h">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
<span>New</span>
|
||||
</div>
|
||||
<button class="add-btn" title="New Identity" (click)="onClickNewIdentity()">
|
||||
<span class="emoji">➕</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
title="Manage whitelisted apps"
|
||||
(click)="onClickWhitelistedApps()"
|
||||
>
|
||||
<i class="bi bi-gear"></i>
|
||||
<span class="emoji">⚙️</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
/>
|
||||
<span class="name">{{ getDisplayName(identity) }}</span>
|
||||
<lib-icon-button
|
||||
icon="gear"
|
||||
icon="⚙️"
|
||||
title="Identity settings"
|
||||
(click)="onClickEditIdentity(identity.id, $event)"
|
||||
></lib-icon-button>
|
||||
|
||||
@@ -3,37 +3,58 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
|
||||
> *:not(.custom-header) {
|
||||
margin-left: var(--size);
|
||||
margin-right: var(--size);
|
||||
}
|
||||
|
||||
.custom-header {
|
||||
padding-top: var(--size);
|
||||
padding-bottom: var(--size);
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto;
|
||||
align-items: center;
|
||||
background: var(--background);
|
||||
position: relative;
|
||||
|
||||
.button {
|
||||
grid-column-start: 1;
|
||||
grid-column-end: 2;
|
||||
grid-row-start: 1;
|
||||
grid-row-end: 2;
|
||||
justify-self: end;
|
||||
.lock-btn,
|
||||
.add-btn {
|
||||
position: absolute;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background-light);
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.lock-btn {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
grid-column-start: 1;
|
||||
grid-column-end: 2;
|
||||
grid-row-start: 1;
|
||||
grid-row-end: 2;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1rem;
|
||||
justify-self: center;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Router } from '@angular/router';
|
||||
import {
|
||||
IconButtonComponent,
|
||||
Identity_DECRYPTED,
|
||||
LoggerService,
|
||||
NostrHelper,
|
||||
ProfileMetadata,
|
||||
ProfileMetadataService,
|
||||
@@ -20,6 +21,7 @@ export class IdentitiesComponent implements OnInit {
|
||||
readonly storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
readonly #profileMetadata = inject(ProfileMetadataService);
|
||||
readonly #logger = inject(LoggerService);
|
||||
|
||||
// Cache of pubkey -> profile for quick lookup
|
||||
#profileCache = new Map<string, ProfileMetadata | null>();
|
||||
@@ -73,4 +75,10 @@ export class IdentitiesComponent implements OnInit {
|
||||
onClickWhitelistedApps() {
|
||||
this.#router.navigateByUrl('/whitelisted-apps');
|
||||
}
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
|
||||
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<span>You</span>
|
||||
<button class="edit-btn" title="Edit profile" (click)="onClickEditProfile()">
|
||||
<span class="emoji">📝</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="identity-container">
|
||||
@@ -22,7 +29,6 @@
|
||||
</div>
|
||||
|
||||
<!-- Display name (primary, large) -->
|
||||
<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->
|
||||
<div class="name-badge-container" (click)="onClickShowDetails()">
|
||||
<span class="display-name">
|
||||
{{ displayName || selectedIdentity?.nick || 'Unknown' }}
|
||||
|
||||
@@ -3,6 +3,30 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.sam-text-header {
|
||||
.edit-btn {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background-light);
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.identity-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -123,6 +147,7 @@
|
||||
}
|
||||
|
||||
.nip05-row {
|
||||
@extend %text-badge;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@@ -134,7 +159,6 @@
|
||||
}
|
||||
|
||||
.nip05-badge {
|
||||
@extend %text-badge;
|
||||
font-size: 13px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, inject, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
Identity_DECRYPTED,
|
||||
LoggerService,
|
||||
NostrHelper,
|
||||
ProfileMetadata,
|
||||
ProfileMetadataService,
|
||||
@@ -9,8 +10,8 @@ import {
|
||||
StorageService,
|
||||
ToastComponent,
|
||||
VisualNip05Pipe,
|
||||
validateNip05,
|
||||
} from '@common';
|
||||
import NDK from '@nostr-dev-kit/ndk';
|
||||
|
||||
@Component({
|
||||
selector: 'app-identity',
|
||||
@@ -29,6 +30,7 @@ export class IdentityComponent implements OnInit {
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
readonly #profileMetadata = inject(ProfileMetadataService);
|
||||
readonly #logger = inject(LoggerService);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.#loadData();
|
||||
@@ -67,6 +69,19 @@ export class IdentityComponent implements OnInit {
|
||||
);
|
||||
}
|
||||
|
||||
onClickEditProfile() {
|
||||
if (!this.selectedIdentity) {
|
||||
return;
|
||||
}
|
||||
this.#router.navigateByUrl('/profile-edit');
|
||||
}
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.#storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
|
||||
async #loadData() {
|
||||
try {
|
||||
const selectedIdentityId =
|
||||
@@ -125,28 +140,21 @@ export class IdentityComponent implements OnInit {
|
||||
try {
|
||||
this.validating = true;
|
||||
|
||||
// Get relays for validation
|
||||
const relays =
|
||||
this.#storage
|
||||
.getBrowserSessionHandler()
|
||||
.browserSessionData?.relays.filter(
|
||||
(x) => x.identityId === this.selectedIdentity?.id
|
||||
) ?? [];
|
||||
// Direct NIP-05 validation - fetches .well-known/nostr.json directly
|
||||
const result = await validateNip05(nip05, pubkey);
|
||||
this.nip05isValidated = result.valid;
|
||||
|
||||
const relevantRelays = relays.filter((x) => x.write).map((x) => x.url);
|
||||
|
||||
if (relevantRelays.length > 0) {
|
||||
const ndk = new NDK({
|
||||
explicitRelayUrls: relevantRelays,
|
||||
});
|
||||
await ndk.connect();
|
||||
const user = ndk.getUser({ pubkey });
|
||||
this.nip05isValidated = (await user.validateNip05(nip05)) ?? undefined;
|
||||
if (result.valid) {
|
||||
this.#logger.logNip05ValidationSuccess(nip05, pubkey);
|
||||
} else {
|
||||
this.#logger.logNip05ValidationError(nip05, result.error ?? 'Unknown error');
|
||||
}
|
||||
|
||||
this.validating = false;
|
||||
} catch (error) {
|
||||
console.error('NIP-05 validation failed:', error);
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.#logger.logNip05ValidationError(nip05, errorMsg);
|
||||
this.nip05isValidated = false;
|
||||
this.validating = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<span> Plebeian Signer </span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
overflow-y: auto;
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
|
||||
> *:not(.sam-text-header) {
|
||||
margin-left: var(--size);
|
||||
margin-right: var(--size);
|
||||
}
|
||||
|
||||
.sam-text-header {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { LoggerService, StorageService } from '@common';
|
||||
import packageJson from '../../../../../../../package.json';
|
||||
|
||||
@Component({
|
||||
@@ -7,5 +9,15 @@ import packageJson from '../../../../../../../package.json';
|
||||
styleUrl: './info.component.scss',
|
||||
})
|
||||
export class InfoComponent {
|
||||
readonly #logger = inject(LoggerService);
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
|
||||
version = packageJson.custom.chrome.version;
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.#storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<span>Logs</span>
|
||||
<div class="logs-actions">
|
||||
<button class="btn btn-sm btn-secondary" title="Refresh logs" (click)="onRefresh()">Refresh</button>
|
||||
<button class="btn btn-sm btn-secondary" title="Clear logs" (click)="onClear()">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logs-container">
|
||||
@if (logs.length === 0) {
|
||||
<div class="logs-empty">No activity logged yet</div>
|
||||
}
|
||||
@for (log of logs; track log.timestamp) {
|
||||
<div class="log-entry" [class]="getLevelClass(log.level)">
|
||||
<span class="log-icon emoji">{{ log.icon }}</span>
|
||||
<span class="log-time">{{ log.timestamp | date:'HH:mm:ss' }}</span>
|
||||
<span class="log-message">{{ log.message }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,88 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: var(--size);
|
||||
padding-bottom: var(--size);
|
||||
overflow: hidden;
|
||||
|
||||
> *:not(.sam-text-header) {
|
||||
margin-left: var(--size);
|
||||
margin-right: var(--size);
|
||||
}
|
||||
|
||||
.sam-text-header {
|
||||
margin-bottom: var(--size);
|
||||
flex-shrink: 0;
|
||||
|
||||
.logs-actions {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logs-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: var(--background-light);
|
||||
border-radius: 8px;
|
||||
padding: var(--size-h);
|
||||
}
|
||||
|
||||
.logs-empty {
|
||||
color: var(--muted-foreground);
|
||||
text-align: center;
|
||||
padding: var(--size);
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 2px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
&.log-error {
|
||||
background: rgba(220, 53, 69, 0.15);
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
&.log-warn {
|
||||
background: rgba(255, 193, 7, 0.15);
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
&.log-debug {
|
||||
background: rgba(108, 117, 125, 0.15);
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
&.log-info {
|
||||
background: rgba(13, 110, 253, 0.1);
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.log-icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: var(--muted-foreground);
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.log-message {
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { LoggerService, LogEntry, StorageService } from '@common';
|
||||
import { DatePipe } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-logs',
|
||||
templateUrl: './logs.component.html',
|
||||
styleUrl: './logs.component.scss',
|
||||
imports: [DatePipe],
|
||||
})
|
||||
export class LogsComponent implements OnInit {
|
||||
readonly #logger = inject(LoggerService);
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
|
||||
get logs(): LogEntry[] {
|
||||
return this.#logger.logs;
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
// Refresh logs from storage to get background script logs
|
||||
this.#logger.refreshLogs();
|
||||
}
|
||||
|
||||
async onRefresh() {
|
||||
await this.#logger.refreshLogs();
|
||||
}
|
||||
|
||||
async onClear() {
|
||||
await this.#logger.clear();
|
||||
}
|
||||
|
||||
getLevelClass(level: LogEntry['level']): string {
|
||||
switch (level) {
|
||||
case 'error':
|
||||
return 'log-error';
|
||||
case 'warn':
|
||||
return 'log-warn';
|
||||
case 'debug':
|
||||
return 'log-debug';
|
||||
default:
|
||||
return 'log-info';
|
||||
}
|
||||
}
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.#storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<span> Settings </span>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +15,9 @@
|
||||
Import Vault
|
||||
</button>
|
||||
|
||||
<lib-nav-item text="🪵 Logs" (click)="navigate('/home/logs')"></lib-nav-item>
|
||||
<lib-nav-item text="💡 Info" (click)="navigate('/home/info')"></lib-nav-item>
|
||||
|
||||
<div class="sam-flex-grow"></div>
|
||||
|
||||
<button
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
flex-direction: column;
|
||||
row-gap: var(--size);
|
||||
overflow-y: auto;
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
|
||||
> *:not(.sam-text-header) {
|
||||
margin-left: var(--size);
|
||||
margin-right: var(--size);
|
||||
}
|
||||
|
||||
.file-input {
|
||||
position: absolute;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
BrowserSyncData,
|
||||
BrowserSyncFlow,
|
||||
ConfirmComponent,
|
||||
DateHelper,
|
||||
LoggerService,
|
||||
NavComponent,
|
||||
NavItemComponent,
|
||||
StartupService,
|
||||
StorageService,
|
||||
} from '@common';
|
||||
@@ -12,15 +15,17 @@ import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
imports: [ConfirmComponent],
|
||||
imports: [ConfirmComponent, NavItemComponent],
|
||||
templateUrl: './settings.component.html',
|
||||
styleUrl: './settings.component.scss',
|
||||
})
|
||||
export class SettingsComponent extends NavComponent implements OnInit {
|
||||
readonly #router = inject(Router);
|
||||
syncFlow: string | undefined;
|
||||
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #startup = inject(StartupService);
|
||||
readonly #logger = inject(LoggerService);
|
||||
|
||||
ngOnInit(): void {
|
||||
const vault = JSON.stringify(
|
||||
@@ -44,6 +49,7 @@ export class SettingsComponent extends NavComponent implements OnInit {
|
||||
|
||||
async onResetExtension() {
|
||||
try {
|
||||
this.#logger.logVaultReset();
|
||||
await this.#storage.resetExtension();
|
||||
this.#startup.startOver(getNewStorageServiceConfig());
|
||||
} catch (error) {
|
||||
@@ -69,6 +75,7 @@ export class SettingsComponent extends NavComponent implements OnInit {
|
||||
|
||||
await this.#storage.deleteVault(true);
|
||||
await this.#storage.importVault(vault);
|
||||
this.#logger.logVaultImport(file.name);
|
||||
this.#storage.isInitialized = false;
|
||||
this.#startup.startOver(getNewStorageServiceConfig());
|
||||
} catch (error) {
|
||||
@@ -84,6 +91,7 @@ export class SettingsComponent extends NavComponent implements OnInit {
|
||||
const fileName = `Plebeian Signer Chrome - Vault Export - ${dateTimeString}.json`;
|
||||
|
||||
this.#downloadJson(jsonVault, fileName);
|
||||
this.#logger.logVaultExport(fileName);
|
||||
}
|
||||
|
||||
#downloadJson(jsonString: string, fileName: string) {
|
||||
@@ -96,4 +104,10 @@ export class SettingsComponent extends NavComponent implements OnInit {
|
||||
downloadAnchorNode.click();
|
||||
downloadAnchorNode.remove();
|
||||
}
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.#storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<span>Wallet</span>
|
||||
</div>
|
||||
|
||||
<div class="wallet-container">
|
||||
<div class="empty-state">
|
||||
<span class="sam-text-muted">
|
||||
Wallet functionality coming soon.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: var(--size);
|
||||
padding-bottom: var(--size);
|
||||
overflow: hidden;
|
||||
|
||||
> *:not(.sam-text-header) {
|
||||
margin-left: var(--size);
|
||||
margin-right: var(--size);
|
||||
}
|
||||
|
||||
.sam-text-header {
|
||||
margin-bottom: var(--size);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.wallet-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { LoggerService, StorageService } from '@common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-wallet',
|
||||
templateUrl: './wallet.component.html',
|
||||
styleUrl: './wallet.component.scss',
|
||||
imports: [],
|
||||
})
|
||||
export class WalletComponent {
|
||||
readonly #logger = inject(LoggerService);
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.#storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<div class="sam-text-header">
|
||||
<span>Edit Profile</span>
|
||||
</div>
|
||||
|
||||
@if(loading) {
|
||||
<div class="loading-container">
|
||||
<span class="sam-text-muted">Loading profile...</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="content">
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Your name"
|
||||
class="form-control"
|
||||
[(ngModel)]="profile.name"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="display_name">Display Name</label>
|
||||
<input
|
||||
id="display_name"
|
||||
type="text"
|
||||
placeholder="Display name"
|
||||
class="form-control"
|
||||
[(ngModel)]="profile.display_name"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="picture">Avatar URL</label>
|
||||
<input
|
||||
id="picture"
|
||||
type="url"
|
||||
placeholder="https://example.com/avatar.jpg"
|
||||
class="form-control"
|
||||
[(ngModel)]="profile.picture"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="banner">Banner URL</label>
|
||||
<input
|
||||
id="banner"
|
||||
type="url"
|
||||
placeholder="https://example.com/banner.jpg"
|
||||
class="form-control"
|
||||
[(ngModel)]="profile.banner"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="website">Website</label>
|
||||
<input
|
||||
id="website"
|
||||
type="url"
|
||||
placeholder="https://yourwebsite.com"
|
||||
class="form-control"
|
||||
[(ngModel)]="profile.website"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="about">About</label>
|
||||
<textarea
|
||||
id="about"
|
||||
placeholder="Tell us about yourself..."
|
||||
class="form-control"
|
||||
rows="4"
|
||||
[(ngModel)]="profile.about"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="nip05">NIP-05 Identifier</label>
|
||||
<input
|
||||
id="nip05"
|
||||
type="text"
|
||||
placeholder="you@example.com"
|
||||
class="form-control"
|
||||
[(ngModel)]="profile.nip05"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="lud16">Lightning Address (LUD-16)</label>
|
||||
<input
|
||||
id="lud16"
|
||||
type="text"
|
||||
placeholder="you@getalby.com"
|
||||
class="form-control"
|
||||
[(ngModel)]="profile.lud16"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="lnurl">LNURL</label>
|
||||
<input
|
||||
id="lnurl"
|
||||
type="text"
|
||||
placeholder="lnurl1..."
|
||||
class="form-control"
|
||||
[(ngModel)]="profile.lnurl"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sam-footer-grid-2">
|
||||
<button type="button" class="btn btn-secondary" (click)="onClickCancel()">
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
[disabled]="saving"
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
(click)="onClickSave()"
|
||||
>
|
||||
@if(saving) {
|
||||
Saving...
|
||||
} @else {
|
||||
Save
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if(alertMessage) {
|
||||
<div class="alert-container">
|
||||
<div class="alert alert-danger sam-flex-row gap" role="alert">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
<span>{{ alertMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<lib-toast #toast></lib-toast>
|
||||
@@ -0,0 +1,69 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.loading-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
padding-bottom: var(--size);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--muted-foreground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
font-size: 14px;
|
||||
background: var(--background-light);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--foreground);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 12px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.2);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
textarea.form-control {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-container {
|
||||
position: absolute;
|
||||
bottom: 70px;
|
||||
left: var(--size);
|
||||
right: var(--size);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
FALLBACK_PROFILE_RELAYS,
|
||||
NavComponent,
|
||||
NostrHelper,
|
||||
ProfileMetadataService,
|
||||
RelayListService,
|
||||
StorageService,
|
||||
ToastComponent,
|
||||
publishToRelaysWithAuth,
|
||||
} from '@common';
|
||||
import { SimplePool } from 'nostr-tools/pool';
|
||||
import { finalizeEvent } from 'nostr-tools';
|
||||
import { hexToBytes } from '@noble/hashes/utils';
|
||||
|
||||
interface ProfileFormData {
|
||||
name: string;
|
||||
display_name: string;
|
||||
picture: string;
|
||||
banner: string;
|
||||
website: string;
|
||||
about: string;
|
||||
nip05: string;
|
||||
lud16: string;
|
||||
lnurl: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-profile-edit',
|
||||
templateUrl: './profile-edit.component.html',
|
||||
styleUrl: './profile-edit.component.scss',
|
||||
imports: [FormsModule, ToastComponent],
|
||||
})
|
||||
export class ProfileEditComponent extends NavComponent implements OnInit {
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
readonly #profileMetadata = inject(ProfileMetadataService);
|
||||
readonly #relayList = inject(RelayListService);
|
||||
|
||||
profile: ProfileFormData = {
|
||||
name: '',
|
||||
display_name: '',
|
||||
picture: '',
|
||||
banner: '',
|
||||
website: '',
|
||||
about: '',
|
||||
nip05: '',
|
||||
lud16: '',
|
||||
lnurl: '',
|
||||
};
|
||||
|
||||
// Store original event content to preserve extra fields
|
||||
#originalContent: Record<string, unknown> = {};
|
||||
#originalTags: string[][] = [];
|
||||
|
||||
loading = true;
|
||||
saving = false;
|
||||
alertMessage: string | undefined;
|
||||
#privkey: string | undefined;
|
||||
#pubkey: string | undefined;
|
||||
|
||||
async ngOnInit() {
|
||||
await this.#loadProfile();
|
||||
}
|
||||
|
||||
async #loadProfile() {
|
||||
try {
|
||||
const selectedIdentityId =
|
||||
this.#storage.getBrowserSessionHandler().browserSessionData
|
||||
?.selectedIdentityId ?? null;
|
||||
|
||||
const identity = this.#storage
|
||||
.getBrowserSessionHandler()
|
||||
.browserSessionData?.identities.find(
|
||||
(x) => x.id === selectedIdentityId
|
||||
);
|
||||
|
||||
if (!identity) {
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.#privkey = identity.privkey;
|
||||
this.#pubkey = NostrHelper.pubkeyFromPrivkey(identity.privkey);
|
||||
|
||||
// Initialize services
|
||||
await this.#profileMetadata.initialize();
|
||||
|
||||
// Try to get cached profile first
|
||||
const cachedProfile = this.#profileMetadata.getCachedProfile(this.#pubkey);
|
||||
if (cachedProfile) {
|
||||
this.profile = {
|
||||
name: cachedProfile.name || '',
|
||||
display_name: cachedProfile.display_name || cachedProfile.displayName || '',
|
||||
picture: cachedProfile.picture || '',
|
||||
banner: cachedProfile.banner || '',
|
||||
website: cachedProfile.website || '',
|
||||
about: cachedProfile.about || '',
|
||||
nip05: cachedProfile.nip05 || '',
|
||||
lud16: cachedProfile.lud16 || '',
|
||||
lnurl: cachedProfile.lud06 || '',
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch the actual kind 0 event to get original content and tags
|
||||
await this.#fetchOriginalEvent();
|
||||
|
||||
this.loading = false;
|
||||
} catch (error) {
|
||||
console.error('Failed to load profile:', error);
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async #fetchOriginalEvent() {
|
||||
if (!this.#pubkey) return;
|
||||
|
||||
const pool = new SimplePool();
|
||||
try {
|
||||
const events = await this.#queryWithTimeout(
|
||||
pool,
|
||||
FALLBACK_PROFILE_RELAYS,
|
||||
[{ kinds: [0], authors: [this.#pubkey] }],
|
||||
10000
|
||||
);
|
||||
|
||||
if (events.length > 0) {
|
||||
// Get the most recent event
|
||||
const latestEvent = events.reduce((latest, event) =>
|
||||
event.created_at > latest.created_at ? event : latest
|
||||
);
|
||||
|
||||
// Store original tags (excluding the ones we'll update)
|
||||
this.#originalTags = latestEvent.tags.filter(
|
||||
(tag: string[]) =>
|
||||
tag[0] !== 'name' &&
|
||||
tag[0] !== 'display_name' &&
|
||||
tag[0] !== 'picture' &&
|
||||
tag[0] !== 'banner' &&
|
||||
tag[0] !== 'website' &&
|
||||
tag[0] !== 'about' &&
|
||||
tag[0] !== 'nip05' &&
|
||||
tag[0] !== 'lud16' &&
|
||||
tag[0] !== 'client'
|
||||
);
|
||||
|
||||
// Parse and store original content
|
||||
try {
|
||||
this.#originalContent = JSON.parse(latestEvent.content);
|
||||
|
||||
// Update form with values from event content
|
||||
this.profile = {
|
||||
name: (this.#originalContent['name'] as string) || '',
|
||||
display_name:
|
||||
(this.#originalContent['display_name'] as string) ||
|
||||
(this.#originalContent['displayName'] as string) ||
|
||||
'',
|
||||
picture: (this.#originalContent['picture'] as string) || '',
|
||||
banner: (this.#originalContent['banner'] as string) || '',
|
||||
website: (this.#originalContent['website'] as string) || '',
|
||||
about: (this.#originalContent['about'] as string) || '',
|
||||
nip05: (this.#originalContent['nip05'] as string) || '',
|
||||
lud16: (this.#originalContent['lud16'] as string) || '',
|
||||
lnurl: (this.#originalContent['lnurl'] as string) || '',
|
||||
};
|
||||
} catch {
|
||||
console.error('Failed to parse profile content');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pool.close(FALLBACK_PROFILE_RELAYS);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async #queryWithTimeout(pool: SimplePool, relays: string[], filters: any[], timeoutMs: number): Promise<any[]> {
|
||||
return new Promise((resolve) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const events: any[] = [];
|
||||
let settled = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(events);
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
const sub = pool.subscribeMany(relays, filters, {
|
||||
onevent(event) {
|
||||
events.push(event);
|
||||
},
|
||||
oneose() {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
sub.close();
|
||||
resolve(events);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async onClickSave() {
|
||||
if (this.saving || !this.#privkey || !this.#pubkey) return;
|
||||
|
||||
this.saving = true;
|
||||
this.alertMessage = undefined;
|
||||
|
||||
try {
|
||||
// Build the content JSON, preserving extra fields
|
||||
const content: Record<string, unknown> = { ...this.#originalContent };
|
||||
|
||||
// Update with form values
|
||||
content['name'] = this.profile.name;
|
||||
content['display_name'] = this.profile.display_name;
|
||||
content['displayName'] = this.profile.display_name; // Some clients use this
|
||||
content['picture'] = this.profile.picture;
|
||||
content['banner'] = this.profile.banner;
|
||||
content['website'] = this.profile.website;
|
||||
content['about'] = this.profile.about;
|
||||
content['nip05'] = this.profile.nip05;
|
||||
content['lud16'] = this.profile.lud16;
|
||||
if (this.profile.lnurl) {
|
||||
content['lnurl'] = this.profile.lnurl;
|
||||
}
|
||||
content['pubkey'] = this.#pubkey;
|
||||
|
||||
// Build tags array, preserving extra tags
|
||||
const tags: string[][] = [...this.#originalTags];
|
||||
|
||||
// Add standard tags
|
||||
if (this.profile.name) tags.push(['name', this.profile.name]);
|
||||
if (this.profile.display_name) tags.push(['display_name', this.profile.display_name]);
|
||||
if (this.profile.picture) tags.push(['picture', this.profile.picture]);
|
||||
if (this.profile.banner) tags.push(['banner', this.profile.banner]);
|
||||
if (this.profile.website) tags.push(['website', this.profile.website]);
|
||||
if (this.profile.about) tags.push(['about', this.profile.about]);
|
||||
if (this.profile.nip05) tags.push(['nip05', this.profile.nip05]);
|
||||
if (this.profile.lud16) tags.push(['lud16', this.profile.lud16]);
|
||||
|
||||
// Add alt tag if not present
|
||||
if (!tags.some(t => t[0] === 'alt')) {
|
||||
tags.push(['alt', `User profile for ${this.profile.name || this.profile.display_name || 'user'}`]);
|
||||
}
|
||||
|
||||
// Always add client tag
|
||||
tags.push(['client', 'plebeian-signer']);
|
||||
|
||||
// Create the unsigned event
|
||||
const unsignedEvent = {
|
||||
kind: 0,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
content: JSON.stringify(content),
|
||||
};
|
||||
|
||||
// Sign the event
|
||||
const privkeyBytes = hexToBytes(this.#privkey);
|
||||
const signedEvent = finalizeEvent(unsignedEvent, privkeyBytes);
|
||||
|
||||
// Get write relays from NIP-65 or use fallback
|
||||
await this.#relayList.initialize();
|
||||
const writeRelays = await this.#relayList.fetchRelayList(this.#pubkey);
|
||||
let relayUrls: string[];
|
||||
|
||||
if (writeRelays.length > 0) {
|
||||
// Filter to write relays only
|
||||
relayUrls = writeRelays
|
||||
.filter(r => r.write)
|
||||
.map(r => r.url);
|
||||
|
||||
// If no write relays found, use all relays
|
||||
if (relayUrls.length === 0) {
|
||||
relayUrls = writeRelays.map(r => r.url);
|
||||
}
|
||||
} else {
|
||||
// Use fallback relays
|
||||
relayUrls = FALLBACK_PROFILE_RELAYS;
|
||||
}
|
||||
|
||||
// Publish to relays with NIP-42 authentication support
|
||||
const results = await publishToRelaysWithAuth(
|
||||
relayUrls,
|
||||
signedEvent,
|
||||
this.#privkey
|
||||
);
|
||||
|
||||
// Count successes
|
||||
const successes = results.filter(r => r.success);
|
||||
const failures = results.filter(r => !r.success);
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.log('Some relays failed:', failures.map(f => `${f.relay}: ${f.message}`));
|
||||
}
|
||||
|
||||
if (successes.length === 0) {
|
||||
throw new Error('Failed to publish to any relay');
|
||||
}
|
||||
|
||||
console.log(`Profile published to ${successes.length}/${results.length} relays`);
|
||||
|
||||
// Clear cached profile and refetch
|
||||
await this.#profileMetadata.clearCacheForPubkey(this.#pubkey);
|
||||
await this.#profileMetadata.fetchProfile(this.#pubkey);
|
||||
|
||||
// Navigate back to identity page
|
||||
this.#router.navigateByUrl('/home/identity');
|
||||
} catch (error) {
|
||||
console.error('Failed to save profile:', error);
|
||||
this.alertMessage = error instanceof Error ? error.message : 'Failed to save profile';
|
||||
setTimeout(() => {
|
||||
this.alertMessage = undefined;
|
||||
}, 4500);
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
onClickCancel() {
|
||||
this.#router.navigateByUrl('/home/identity');
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
<app-deriving-modal #derivingModal></app-deriving-modal>
|
||||
|
||||
<div class="sam-text-header">
|
||||
<span>Plebeian Signer</span>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, inject, ViewChild } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { NavComponent, StorageService } from '@common';
|
||||
import { LoggerService, NavComponent, StorageService, DerivingModalComponent } from '@common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-new',
|
||||
imports: [FormsModule],
|
||||
imports: [FormsModule, DerivingModalComponent],
|
||||
templateUrl: './new.component.html',
|
||||
styleUrl: './new.component.scss',
|
||||
})
|
||||
export class NewComponent extends NavComponent {
|
||||
@ViewChild('derivingModal') derivingModal!: DerivingModalComponent;
|
||||
|
||||
password = '';
|
||||
|
||||
readonly #router = inject(Router);
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #logger = inject(LoggerService);
|
||||
|
||||
toggleType(element: HTMLInputElement) {
|
||||
if (element.type === 'password') {
|
||||
@@ -28,7 +31,16 @@ export class NewComponent extends NavComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.#storage.createNewVault(this.password);
|
||||
this.#router.navigateByUrl('/home/identities');
|
||||
// Show deriving modal during key derivation (~3-6 seconds)
|
||||
this.derivingModal.show('Creating secure vault');
|
||||
try {
|
||||
await this.#storage.createNewVault(this.password);
|
||||
this.derivingModal.hide();
|
||||
this.#logger.logVaultCreated();
|
||||
this.#router.navigateByUrl('/home/identities');
|
||||
} catch (error) {
|
||||
this.derivingModal.hide();
|
||||
console.error('Failed to create vault:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<app-deriving-modal #derivingModal></app-deriving-modal>
|
||||
|
||||
<div class="sam-text-header">
|
||||
<span class="brand">Plebeian Signer</span>
|
||||
</div>
|
||||
@@ -41,23 +43,22 @@
|
||||
<span>Sign in</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="sam-mt"
|
||||
(click)="
|
||||
confirm.show(
|
||||
'Do you really want to reset the extension? All data will be lost.',
|
||||
onClickResetExtension.bind(this)
|
||||
)
|
||||
"
|
||||
type="button"
|
||||
class="btn btn-link"
|
||||
>
|
||||
Reset Extension
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="reset-btn"
|
||||
(click)="
|
||||
confirm.show(
|
||||
'Do you really want to reset the extension? All data will be lost.',
|
||||
onClickResetExtension.bind(this)
|
||||
)
|
||||
"
|
||||
type="button"
|
||||
>
|
||||
Reset Extension
|
||||
</button>
|
||||
|
||||
<!----------->
|
||||
<!-- ALERT -->
|
||||
<!----------->
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-items: center;
|
||||
position: relative;
|
||||
|
||||
.logo-frame {
|
||||
border: 2px solid var(--secondary);
|
||||
@@ -16,4 +17,21 @@
|
||||
justify-content: center;
|
||||
padding: 0 var(--size) var(--size) var(--size);
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
position: absolute;
|
||||
bottom: var(--size);
|
||||
right: var(--size);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
|
||||
&:hover {
|
||||
color: var(--foreground);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
ConfirmComponent,
|
||||
DerivingModalComponent,
|
||||
LoggerService,
|
||||
NostrHelper,
|
||||
ProfileMetadataService,
|
||||
StartupService,
|
||||
@@ -14,10 +16,11 @@ import { getNewStorageServiceConfig } from '../../common/data/get-new-storage-se
|
||||
selector: 'app-vault-login',
|
||||
templateUrl: './vault-login.component.html',
|
||||
styleUrl: './vault-login.component.scss',
|
||||
imports: [FormsModule, ConfirmComponent],
|
||||
imports: [FormsModule, ConfirmComponent, DerivingModalComponent],
|
||||
})
|
||||
export class VaultLoginComponent implements AfterViewInit {
|
||||
@ViewChild('passwordInputElement') passwordInput!: ElementRef<HTMLInputElement>;
|
||||
@ViewChild('derivingModal') derivingModal!: DerivingModalComponent;
|
||||
|
||||
loginPassword = '';
|
||||
showInvalidPasswordAlert = false;
|
||||
@@ -26,6 +29,7 @@ export class VaultLoginComponent implements AfterViewInit {
|
||||
readonly #router = inject(Router);
|
||||
readonly #startup = inject(StartupService);
|
||||
readonly #profileMetadata = inject(ProfileMetadataService);
|
||||
readonly #logger = inject(LoggerService);
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.passwordInput.nativeElement.focus();
|
||||
@@ -40,24 +44,39 @@ export class VaultLoginComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
async loginVault() {
|
||||
console.log('[login] loginVault called');
|
||||
if (!this.loginPassword) {
|
||||
console.log('[login] No password, returning');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[login] Showing deriving modal');
|
||||
// Show deriving modal during key derivation (~3-6 seconds)
|
||||
this.derivingModal.show('Unlocking vault');
|
||||
|
||||
try {
|
||||
console.log('[login] Calling unlockVault...');
|
||||
await this.#storage.unlockVault(this.loginPassword);
|
||||
|
||||
// Fetch profile metadata for all identities in the background
|
||||
this.#fetchAllProfiles();
|
||||
|
||||
this.#router.navigateByUrl('/home/identity');
|
||||
console.log('[login] unlockVault succeeded!');
|
||||
} catch (error) {
|
||||
console.error('[login] unlockVault FAILED:', error);
|
||||
this.derivingModal.hide();
|
||||
this.showInvalidPasswordAlert = true;
|
||||
console.log(error);
|
||||
window.setTimeout(() => {
|
||||
this.showInvalidPasswordAlert = false;
|
||||
}, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Unlock succeeded - hide modal and navigate
|
||||
console.log('[login] Hiding modal and navigating');
|
||||
this.derivingModal.hide();
|
||||
this.#logger.logVaultUnlock();
|
||||
|
||||
// Fetch profile metadata for all identities in the background
|
||||
this.#fetchAllProfiles();
|
||||
|
||||
this.#router.navigateByUrl('/home/identity');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,6 +105,7 @@ export class VaultLoginComponent implements AfterViewInit {
|
||||
|
||||
async onClickResetExtension() {
|
||||
try {
|
||||
this.#logger.logVaultReset();
|
||||
await this.#storage.resetExtension();
|
||||
this.#startup.startOver(getNewStorageServiceConfig());
|
||||
} catch (error) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '@common';
|
||||
import { ChromeMetaHandler } from './app/common/data/chrome-meta-handler';
|
||||
import { Event, EventTemplate, finalizeEvent, nip04, nip44 } from 'nostr-tools';
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
export const debug = function (message: any) {
|
||||
const dateString = new Date().toISOString();
|
||||
@@ -66,6 +67,8 @@ export const shouldRecklessModeApprove = async function (
|
||||
host: string
|
||||
): Promise<boolean> {
|
||||
const signerMetaData = await getSignerMetaData();
|
||||
debug(`shouldRecklessModeApprove: recklessMode=${signerMetaData.recklessMode}, host=${host}`);
|
||||
debug(`Full signerMetaData: ${JSON.stringify(signerMetaData)}`);
|
||||
|
||||
if (!signerMetaData.recklessMode) {
|
||||
return false;
|
||||
@@ -223,8 +226,7 @@ export const storePermission = async function (
|
||||
// Encrypt permission to store in sync storage (depending on sync flow).
|
||||
const encryptedPermission = await encryptPermission(
|
||||
permission,
|
||||
browserSessionData.iv,
|
||||
browserSessionData.vaultPassword as string
|
||||
browserSessionData
|
||||
);
|
||||
|
||||
await savePermissionsToBrowserSyncStorage([
|
||||
@@ -321,22 +323,20 @@ export const nip44Decrypt = async function (
|
||||
|
||||
const encryptPermission = async function (
|
||||
permission: Permission_DECRYPTED,
|
||||
iv: string,
|
||||
password: string
|
||||
sessionData: BrowserSessionData
|
||||
): Promise<Permission_ENCRYPTED> {
|
||||
const encryptedPermission: Permission_ENCRYPTED = {
|
||||
id: await encrypt(permission.id, iv, password),
|
||||
identityId: await encrypt(permission.identityId, iv, password),
|
||||
host: await encrypt(permission.host, iv, password),
|
||||
method: await encrypt(permission.method, iv, password),
|
||||
methodPolicy: await encrypt(permission.methodPolicy, iv, password),
|
||||
id: await encrypt(permission.id, sessionData),
|
||||
identityId: await encrypt(permission.identityId, sessionData),
|
||||
host: await encrypt(permission.host, sessionData),
|
||||
method: await encrypt(permission.method, sessionData),
|
||||
methodPolicy: await encrypt(permission.methodPolicy, sessionData),
|
||||
};
|
||||
|
||||
if (typeof permission.kind !== 'undefined') {
|
||||
encryptedPermission.kind = await encrypt(
|
||||
permission.kind.toString(),
|
||||
iv,
|
||||
password
|
||||
sessionData
|
||||
);
|
||||
}
|
||||
|
||||
@@ -345,8 +345,30 @@ const encryptPermission = async function (
|
||||
|
||||
const encrypt = async function (
|
||||
value: string,
|
||||
iv: string,
|
||||
password: string
|
||||
sessionData: BrowserSessionData
|
||||
): Promise<string> {
|
||||
return await CryptoHelper.encrypt(value, iv, password);
|
||||
// v2: Use pre-derived key with AES-GCM directly
|
||||
if (sessionData.vaultKey) {
|
||||
const keyBytes = Buffer.from(sessionData.vaultKey, 'base64');
|
||||
const iv = Buffer.from(sessionData.iv, 'base64');
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt']
|
||||
);
|
||||
|
||||
const cipherText = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
key,
|
||||
new TextEncoder().encode(value)
|
||||
);
|
||||
|
||||
return Buffer.from(cipherText).toString('base64');
|
||||
}
|
||||
|
||||
// v1: Use password with PBKDF2
|
||||
return await CryptoHelper.encrypt(value, sessionData.iv, sessionData.vaultPassword!);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { NostrHelper } from '@common';
|
||||
import {
|
||||
backgroundLogNip07Action,
|
||||
backgroundLogPermissionStored,
|
||||
NostrHelper,
|
||||
} from '@common';
|
||||
import {
|
||||
BackgroundRequestMessage,
|
||||
checkPermissions,
|
||||
@@ -67,6 +71,7 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
||||
|
||||
// Check reckless mode first
|
||||
const recklessApprove = await shouldRecklessModeApprove(req.host);
|
||||
debug(`recklessApprove result: ${recklessApprove}`);
|
||||
if (recklessApprove) {
|
||||
debug('Request auto-approved via reckless mode.');
|
||||
} else {
|
||||
@@ -78,6 +83,7 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
||||
req.method,
|
||||
req.params
|
||||
);
|
||||
debug(`permissionState result: ${permissionState}`);
|
||||
|
||||
if (permissionState === false) {
|
||||
throw new Error('Permission denied');
|
||||
@@ -107,17 +113,28 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
||||
});
|
||||
debug(response);
|
||||
if (response === 'approve' || response === 'reject') {
|
||||
const policy = response === 'approve' ? 'allow' : 'deny';
|
||||
await storePermission(
|
||||
browserSessionData,
|
||||
currentIdentity,
|
||||
req.host,
|
||||
req.method,
|
||||
response === 'approve' ? 'allow' : 'deny',
|
||||
policy,
|
||||
req.params?.kind
|
||||
);
|
||||
await backgroundLogPermissionStored(
|
||||
req.host,
|
||||
req.method,
|
||||
policy,
|
||||
req.params?.kind
|
||||
);
|
||||
}
|
||||
|
||||
if (['reject', 'reject-once'].includes(response)) {
|
||||
await backgroundLogNip07Action(req.method, req.host, false, false, {
|
||||
kind: req.params?.kind,
|
||||
peerPubkey: req.params?.peerPubkey,
|
||||
});
|
||||
throw new Error('Permission denied');
|
||||
}
|
||||
} else {
|
||||
@@ -126,46 +143,71 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
||||
}
|
||||
|
||||
const relays: Relays = {};
|
||||
let result: any;
|
||||
|
||||
switch (req.method) {
|
||||
case 'getPublicKey':
|
||||
return NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey);
|
||||
result = NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey);
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove);
|
||||
return result;
|
||||
|
||||
case 'signEvent':
|
||||
return signEvent(req.params, currentIdentity.privkey);
|
||||
result = signEvent(req.params, currentIdentity.privkey);
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
|
||||
kind: req.params?.kind,
|
||||
});
|
||||
return result;
|
||||
|
||||
case 'getRelays':
|
||||
browserSessionData.relays.forEach((x) => {
|
||||
relays[x.url] = { read: x.read, write: x.write };
|
||||
});
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove);
|
||||
return relays;
|
||||
|
||||
case 'nip04.encrypt':
|
||||
return await nip04Encrypt(
|
||||
result = await nip04Encrypt(
|
||||
currentIdentity.privkey,
|
||||
req.params.peerPubkey,
|
||||
req.params.plaintext
|
||||
);
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
|
||||
peerPubkey: req.params.peerPubkey,
|
||||
});
|
||||
return result;
|
||||
|
||||
case 'nip44.encrypt':
|
||||
return await nip44Encrypt(
|
||||
result = await nip44Encrypt(
|
||||
currentIdentity.privkey,
|
||||
req.params.peerPubkey,
|
||||
req.params.plaintext
|
||||
);
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
|
||||
peerPubkey: req.params.peerPubkey,
|
||||
});
|
||||
return result;
|
||||
|
||||
case 'nip04.decrypt':
|
||||
return await nip04Decrypt(
|
||||
result = await nip04Decrypt(
|
||||
currentIdentity.privkey,
|
||||
req.params.peerPubkey,
|
||||
req.params.ciphertext
|
||||
);
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
|
||||
peerPubkey: req.params.peerPubkey,
|
||||
});
|
||||
return result;
|
||||
|
||||
case 'nip44.decrypt':
|
||||
return await nip44Decrypt(
|
||||
result = await nip44Decrypt(
|
||||
currentIdentity.privkey,
|
||||
req.params.peerPubkey,
|
||||
req.params.ciphertext
|
||||
);
|
||||
await backgroundLogNip07Action(req.method, req.host, true, recklessApprove, {
|
||||
peerPubkey: req.params.peerPubkey,
|
||||
});
|
||||
return result;
|
||||
|
||||
default:
|
||||
throw new Error(`Not supported request method '${req.method}'.`);
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
import { Event, EventTemplate } from 'nostr-tools';
|
||||
import { Nip07Method } from '@common';
|
||||
|
||||
// Extend Window interface for NIP-07
|
||||
declare global {
|
||||
interface Window {
|
||||
nostr?: any;
|
||||
}
|
||||
}
|
||||
|
||||
type Relays = Record<string, { read: boolean; write: boolean }>;
|
||||
|
||||
// Fallback UUID generator for contexts where crypto.randomUUID is unavailable
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
import browser from 'webextension-polyfill';
|
||||
import { Buffer } from 'buffer';
|
||||
import { Nip07Method } from '@common';
|
||||
import { PromptResponse, PromptResponseMessage } from './background-common';
|
||||
|
||||
/**
|
||||
* Decode base64 string to UTF-8 using native browser APIs.
|
||||
* This avoids race conditions with the Buffer polyfill initialization.
|
||||
*/
|
||||
function base64ToUtf8(base64: string): string {
|
||||
const binaryString = atob(base64);
|
||||
const bytes = Uint8Array.from(binaryString, char => char.charCodeAt(0));
|
||||
return new TextDecoder('utf-8').decode(bytes);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const id = params.get('id') as string;
|
||||
const method = params.get('method') as Nip07Method;
|
||||
const host = params.get('host') as string;
|
||||
const nick = params.get('nick') as string;
|
||||
const event = Buffer.from(params.get('event') as string, 'base64').toString();
|
||||
|
||||
let event = '{}';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let eventParsed: any = {};
|
||||
try {
|
||||
event = base64ToUtf8(params.get('event') as string);
|
||||
eventParsed = JSON.parse(event);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse event:', e);
|
||||
}
|
||||
|
||||
let title = '';
|
||||
switch (method) {
|
||||
@@ -62,8 +80,8 @@ Array.from(document.getElementsByClassName('host-INSERT')).forEach(
|
||||
);
|
||||
|
||||
const kindSpanElement = document.getElementById('kindSpan');
|
||||
if (kindSpanElement) {
|
||||
kindSpanElement.innerText = JSON.parse(event).kind;
|
||||
if (kindSpanElement && eventParsed.kind !== undefined) {
|
||||
kindSpanElement.innerText = eventParsed.kind;
|
||||
}
|
||||
|
||||
const cardGetPublicKeyElement = document.getElementById('cardGetPublicKey');
|
||||
@@ -108,9 +126,8 @@ if (cardNip04EncryptElement && card2Nip04EncryptElement) {
|
||||
'card2Nip04Encrypt_text'
|
||||
);
|
||||
if (card2Nip04Encrypt_textElement) {
|
||||
const eventObject: { peerPubkey: string; plaintext: string } =
|
||||
JSON.parse(event);
|
||||
card2Nip04Encrypt_textElement.innerText = eventObject.plaintext;
|
||||
const eventObject = eventParsed as { peerPubkey: string; plaintext: string };
|
||||
card2Nip04Encrypt_textElement.innerText = eventObject.plaintext || '';
|
||||
}
|
||||
} else {
|
||||
cardNip04EncryptElement.style.display = 'none';
|
||||
@@ -126,9 +143,8 @@ if (cardNip44EncryptElement && card2Nip44EncryptElement) {
|
||||
'card2Nip44Encrypt_text'
|
||||
);
|
||||
if (card2Nip44Encrypt_textElement) {
|
||||
const eventObject: { peerPubkey: string; plaintext: string } =
|
||||
JSON.parse(event);
|
||||
card2Nip44Encrypt_textElement.innerText = eventObject.plaintext;
|
||||
const eventObject = eventParsed as { peerPubkey: string; plaintext: string };
|
||||
card2Nip44Encrypt_textElement.innerText = eventObject.plaintext || '';
|
||||
}
|
||||
} else {
|
||||
cardNip44EncryptElement.style.display = 'none';
|
||||
@@ -143,9 +159,8 @@ if (cardNip04DecryptElement && card2Nip04DecryptElement) {
|
||||
'card2Nip04Decrypt_text'
|
||||
);
|
||||
if (card2Nip04Decrypt_textElement) {
|
||||
const eventObject: { peerPubkey: string; ciphertext: string } =
|
||||
JSON.parse(event);
|
||||
card2Nip04Decrypt_textElement.innerText = eventObject.ciphertext;
|
||||
const eventObject = eventParsed as { peerPubkey: string; ciphertext: string };
|
||||
card2Nip04Decrypt_textElement.innerText = eventObject.ciphertext || '';
|
||||
}
|
||||
} else {
|
||||
cardNip04DecryptElement.style.display = 'none';
|
||||
@@ -161,9 +176,8 @@ if (cardNip44DecryptElement && card2Nip44DecryptElement) {
|
||||
'card2Nip44Decrypt_text'
|
||||
);
|
||||
if (card2Nip44Decrypt_textElement) {
|
||||
const eventObject: { peerPubkey: string; ciphertext: string } =
|
||||
JSON.parse(event);
|
||||
card2Nip44Decrypt_textElement.innerText = eventObject.ciphertext;
|
||||
const eventObject = eventParsed as { peerPubkey: string; ciphertext: string };
|
||||
card2Nip44Decrypt_textElement.innerText = eventObject.ciphertext || '';
|
||||
}
|
||||
} else {
|
||||
cardNip44DecryptElement.style.display = 'none';
|
||||
@@ -175,36 +189,38 @@ if (cardNip44DecryptElement && card2Nip44DecryptElement) {
|
||||
// Functions
|
||||
//
|
||||
|
||||
function deliver(response: PromptResponse) {
|
||||
async function deliver(response: PromptResponse) {
|
||||
const message: PromptResponseMessage = {
|
||||
id,
|
||||
response,
|
||||
};
|
||||
|
||||
browser.runtime.sendMessage(message);
|
||||
try {
|
||||
await browser.runtime.sendMessage(message);
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
}
|
||||
window.close();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const rejectJustOnceButton = document.getElementById('rejectJustOnceButton');
|
||||
rejectJustOnceButton?.addEventListener('click', () => {
|
||||
const rejectOnceButton = document.getElementById('rejectOnceButton');
|
||||
rejectOnceButton?.addEventListener('click', () => {
|
||||
deliver('reject-once');
|
||||
});
|
||||
|
||||
const rejectButton = document.getElementById('rejectButton');
|
||||
rejectButton?.addEventListener('click', () => {
|
||||
const rejectAlwaysButton = document.getElementById('rejectAlwaysButton');
|
||||
rejectAlwaysButton?.addEventListener('click', () => {
|
||||
deliver('reject');
|
||||
});
|
||||
|
||||
const approveJustOnceButton = document.getElementById(
|
||||
'approveJustOnceButton'
|
||||
);
|
||||
approveJustOnceButton?.addEventListener('click', () => {
|
||||
const approveOnceButton = document.getElementById('approveOnceButton');
|
||||
approveOnceButton?.addEventListener('click', () => {
|
||||
deliver('approve-once');
|
||||
});
|
||||
|
||||
const approveButton = document.getElementById('approveButton');
|
||||
approveButton?.addEventListener('click', () => {
|
||||
const approveAlwaysButton = document.getElementById('approveAlwaysButton');
|
||||
approveAlwaysButton?.addEventListener('click', () => {
|
||||
deliver('approve');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ body {
|
||||
background: var(--background);
|
||||
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Button styling to match market
|
||||
@@ -101,3 +102,62 @@ button {
|
||||
border-color: var(--border);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
// Bootstrap modal overrides - always use dark theme for modals
|
||||
.modal-content {
|
||||
background-color: #1a1a1a;
|
||||
border-color: #3d3d3d;
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
border-bottom-color: #3d3d3d;
|
||||
|
||||
.modal-title {
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
filter: invert(1);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
border-top-color: #3d3d3d;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
// Custom scrollbar styling for Chrome
|
||||
* {
|
||||
// Thin scrollbar
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
// Track - black background, transparent by default
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
// Thumb - white, transparent by default
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
// Show scrollbar on hover over scrollable area
|
||||
&:hover::-webkit-scrollbar-track {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
&:hover::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
@if (visible) {
|
||||
<div class="deriving-overlay">
|
||||
<div class="deriving-modal">
|
||||
<div class="deriving-spinner"></div>
|
||||
<h3>{{ message }}</h3>
|
||||
<p class="deriving-note">This may take a few seconds</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Modal always uses dark theme for visibility over any content
|
||||
.deriving-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.deriving-modal {
|
||||
background: #1a1a1a;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
min-width: 280px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid #3d3d3d;
|
||||
|
||||
h3 {
|
||||
margin: 1rem 0 0.5rem;
|
||||
color: #fafafa;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.deriving-note {
|
||||
margin: 0.5rem 0 0;
|
||||
color: #a1a1a1;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.deriving-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid #3d3d3d;
|
||||
border-top-color: #ff3eb5;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-deriving-modal',
|
||||
templateUrl: './deriving-modal.component.html',
|
||||
styleUrl: './deriving-modal.component.scss',
|
||||
})
|
||||
export class DerivingModalComponent {
|
||||
visible = false;
|
||||
message = 'Deriving encryption key';
|
||||
|
||||
/**
|
||||
* Show the deriving modal
|
||||
* @param message Optional custom message
|
||||
*/
|
||||
show(message?: string): void {
|
||||
if (message) {
|
||||
this.message = message;
|
||||
}
|
||||
this.visible = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the modal
|
||||
*/
|
||||
hide(): void {
|
||||
this.visible = false;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
<div class="icon-button">
|
||||
<i [class]="'bi bi-' + icon"></i>
|
||||
@if (isEmoji) {
|
||||
<span class="emoji">{{ icon }}</span>
|
||||
} @else {
|
||||
<i [class]="'bi bi-' + icon"></i>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -9,4 +9,9 @@ import { Component, Input } from '@angular/core';
|
||||
})
|
||||
export class IconButtonComponent {
|
||||
@Input({ required: true }) icon!: string;
|
||||
|
||||
get isEmoji(): boolean {
|
||||
// Check if the icon is an emoji (starts with a non-ASCII character)
|
||||
return this.icon.length > 0 && this.icon.charCodeAt(0) > 255;
|
||||
}
|
||||
}
|
||||
|
||||
1756
projects/common/src/lib/constants/event-kinds.ts
Normal file
150
projects/common/src/lib/helpers/argon2-crypto.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Secure vault encryption/decryption using Argon2id + AES-GCM
|
||||
*
|
||||
* - Argon2id key derivation with ~3 second computation time
|
||||
* - AES-256-GCM authenticated encryption
|
||||
* - Random 32-byte salt per vault
|
||||
* - Random 12-byte IV per encryption
|
||||
*
|
||||
* Note: Uses main thread for Argon2id (via WebAssembly) because Web Workers
|
||||
* in browser extensions cannot load external scripts due to CSP restrictions.
|
||||
* The deriving modal provides user feedback during the ~3 second derivation.
|
||||
*/
|
||||
|
||||
import { argon2id } from 'hash-wasm';
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
// Argon2id parameters tuned for ~3 second derivation on typical hardware
|
||||
const ARGON2_CONFIG = {
|
||||
parallelism: 4, // 4 threads
|
||||
iterations: 8, // Time cost
|
||||
memorySize: 262144, // 256 MB memory
|
||||
hashLength: 32, // 256-bit key for AES-256
|
||||
outputType: 'binary' as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Derive an encryption key from password using Argon2id
|
||||
* @param password - User's password
|
||||
* @param salt - Random 32-byte salt
|
||||
* @returns 32-byte derived key
|
||||
*/
|
||||
export async function deriveKeyArgon2(
|
||||
password: string,
|
||||
salt: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
// Use hash-wasm's argon2id (WebAssembly-based, runs on main thread)
|
||||
// This blocks the UI for ~3 seconds, which is why we show a modal
|
||||
const result = await argon2id({
|
||||
password: password,
|
||||
salt: salt,
|
||||
...ARGON2_CONFIG,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random salt for Argon2id
|
||||
* @returns Base64 encoded 32-byte salt
|
||||
*/
|
||||
export function generateSalt(): string {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(32));
|
||||
return Buffer.from(salt).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random IV for AES-GCM
|
||||
* @returns Base64 encoded 12-byte IV
|
||||
*/
|
||||
export function generateIV(): string {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
return Buffer.from(iv).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt data using Argon2id-derived key + AES-256-GCM
|
||||
* @param plaintext - Data to encrypt
|
||||
* @param password - User's password
|
||||
* @param saltBase64 - Base64 encoded 32-byte salt
|
||||
* @param ivBase64 - Base64 encoded 12-byte IV
|
||||
* @returns Base64 encoded ciphertext
|
||||
*/
|
||||
export async function encryptWithArgon2(
|
||||
plaintext: string,
|
||||
password: string,
|
||||
saltBase64: string,
|
||||
ivBase64: string
|
||||
): Promise<string> {
|
||||
const salt = Buffer.from(saltBase64, 'base64');
|
||||
const iv = Buffer.from(ivBase64, 'base64');
|
||||
|
||||
// Derive key using Argon2id (~3 seconds, in worker)
|
||||
const keyBytes = await deriveKeyArgon2(password, salt);
|
||||
|
||||
// Import key for AES-GCM
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt']
|
||||
);
|
||||
|
||||
// Encrypt the data
|
||||
const encoder = new TextEncoder();
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: iv },
|
||||
key,
|
||||
encoder.encode(plaintext)
|
||||
);
|
||||
|
||||
return Buffer.from(encrypted).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt data using Argon2id-derived key + AES-256-GCM
|
||||
* @param ciphertextBase64 - Base64 encoded ciphertext
|
||||
* @param password - User's password
|
||||
* @param saltBase64 - Base64 encoded 32-byte salt
|
||||
* @param ivBase64 - Base64 encoded 12-byte IV
|
||||
* @returns Decrypted plaintext
|
||||
* @throws Error if password is wrong or data is corrupted
|
||||
*/
|
||||
export async function decryptWithArgon2(
|
||||
ciphertextBase64: string,
|
||||
password: string,
|
||||
saltBase64: string,
|
||||
ivBase64: string
|
||||
): Promise<string> {
|
||||
const salt = Buffer.from(saltBase64, 'base64');
|
||||
const iv = Buffer.from(ivBase64, 'base64');
|
||||
const ciphertext = Buffer.from(ciphertextBase64, 'base64');
|
||||
|
||||
// Derive key using Argon2id (~3 seconds, in worker)
|
||||
const keyBytes = await deriveKeyArgon2(password, salt);
|
||||
|
||||
// Import key for AES-GCM
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
|
||||
// Decrypt
|
||||
let decrypted;
|
||||
try {
|
||||
decrypted = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: iv },
|
||||
key,
|
||||
ciphertext
|
||||
);
|
||||
} catch {
|
||||
throw new Error('Decryption failed - invalid password or corrupted data');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(decrypted);
|
||||
}
|
||||
|
||||
127
projects/common/src/lib/helpers/nip05-validator.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* NIP-05 Verification Helper
|
||||
*
|
||||
* Directly validates NIP-05 identifiers by fetching the .well-known/nostr.json
|
||||
* file and comparing the pubkey.
|
||||
*/
|
||||
|
||||
export interface Nip05ValidationResult {
|
||||
valid: boolean;
|
||||
pubkey?: string;
|
||||
relays?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a NIP-05 identifier into its components
|
||||
* @param nip05 - The NIP-05 identifier (e.g., "me@mleku.dev" or "_@mleku.dev")
|
||||
* @returns Object with name and domain, or null if invalid
|
||||
*/
|
||||
export function parseNip05(nip05: string): { name: string; domain: string } | null {
|
||||
if (!nip05 || typeof nip05 !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = nip05.toLowerCase().trim().split('@');
|
||||
if (parts.length !== 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [name, domain] = parts;
|
||||
if (!name || !domain) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Basic domain validation
|
||||
if (!domain.includes('.') || domain.includes('/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { name, domain };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a NIP-05 identifier against a pubkey
|
||||
*
|
||||
* @param nip05 - The NIP-05 identifier (e.g., "me@mleku.dev")
|
||||
* @param expectedPubkey - The expected pubkey in hex format
|
||||
* @param timeoutMs - Fetch timeout in milliseconds
|
||||
* @returns Validation result with status and any discovered relays
|
||||
*/
|
||||
export async function validateNip05(
|
||||
nip05: string,
|
||||
expectedPubkey: string,
|
||||
timeoutMs = 10000
|
||||
): Promise<Nip05ValidationResult> {
|
||||
const parsed = parseNip05(nip05);
|
||||
if (!parsed) {
|
||||
return { valid: false, error: 'Invalid NIP-05 format' };
|
||||
}
|
||||
|
||||
const { name, domain } = parsed;
|
||||
const url = `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Check if the names object exists and contains the requested name
|
||||
if (!data.names || typeof data.names !== 'object') {
|
||||
return { valid: false, error: 'Invalid nostr.json structure: missing names' };
|
||||
}
|
||||
|
||||
// NIP-05 names are case-insensitive
|
||||
const pubkeyFromJson = data.names[name] || data.names[name.toLowerCase()];
|
||||
|
||||
if (!pubkeyFromJson) {
|
||||
return { valid: false, error: `Name "${name}" not found in nostr.json` };
|
||||
}
|
||||
|
||||
// Compare pubkeys (case-insensitive hex comparison)
|
||||
const normalizedExpected = expectedPubkey.toLowerCase();
|
||||
const normalizedFound = pubkeyFromJson.toLowerCase();
|
||||
const valid = normalizedExpected === normalizedFound;
|
||||
|
||||
// Extract relays if present
|
||||
let relays: string[] | undefined;
|
||||
if (data.relays && typeof data.relays === 'object') {
|
||||
const relayList = data.relays[pubkeyFromJson] || data.relays[normalizedFound];
|
||||
if (Array.isArray(relayList)) {
|
||||
relays = relayList;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid,
|
||||
pubkey: pubkeyFromJson,
|
||||
relays,
|
||||
error: valid ? undefined : 'Pubkey mismatch',
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'AbortError') {
|
||||
return { valid: false, error: 'Request timeout' };
|
||||
}
|
||||
return { valid: false, error: error.message };
|
||||
}
|
||||
return { valid: false, error: 'Unknown error' };
|
||||
}
|
||||
}
|
||||
324
projects/common/src/lib/helpers/websocket-auth.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* NIP-42 Relay Authentication
|
||||
*
|
||||
* Handles WebSocket connections to relays that require authentication.
|
||||
* When a relay sends an AUTH challenge, this module signs the challenge
|
||||
* and authenticates before proceeding with event publishing.
|
||||
*/
|
||||
|
||||
import { finalizeEvent, getPublicKey } from 'nostr-tools';
|
||||
|
||||
export interface AuthenticatedRelayConnection {
|
||||
ws: WebSocket;
|
||||
url: string;
|
||||
authenticated: boolean;
|
||||
pubkey: string;
|
||||
}
|
||||
|
||||
export interface PublishResult {
|
||||
relay: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a NIP-42 authentication event (kind 22242)
|
||||
*/
|
||||
function createAuthEvent(
|
||||
relayUrl: string,
|
||||
challenge: string,
|
||||
privateKeyHex: string
|
||||
): ReturnType<typeof finalizeEvent> {
|
||||
const unsignedEvent = {
|
||||
kind: 22242,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['relay', relayUrl],
|
||||
['challenge', challenge],
|
||||
],
|
||||
content: '',
|
||||
};
|
||||
|
||||
// Convert hex private key to Uint8Array
|
||||
const privkeyBytes = hexToBytes(privateKeyHex);
|
||||
return finalizeEvent(unsignedEvent, privkeyBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex string to Uint8Array
|
||||
*/
|
||||
function hexToBytes(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a relay with NIP-42 authentication support
|
||||
*
|
||||
* @param relayUrl - The relay WebSocket URL (e.g., wss://relay.example.com)
|
||||
* @param privateKeyHex - The private key in hex format for signing
|
||||
* @param timeoutMs - Connection and authentication timeout in milliseconds
|
||||
* @returns Promise resolving to authenticated connection or null if failed
|
||||
*/
|
||||
export async function connectWithAuth(
|
||||
relayUrl: string,
|
||||
privateKeyHex: string,
|
||||
timeoutMs = 10000
|
||||
): Promise<AuthenticatedRelayConnection | null> {
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
resolve(null);
|
||||
}, timeoutMs);
|
||||
|
||||
const ws = new WebSocket(relayUrl);
|
||||
const pubkey = getPublicKey(hexToBytes(privateKeyHex));
|
||||
|
||||
ws.onopen = () => {
|
||||
// Connection open, wait for AUTH challenge or proceed directly
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
const messageType = message[0];
|
||||
|
||||
if (messageType === 'AUTH') {
|
||||
// Relay sent an auth challenge
|
||||
const challenge = message[1];
|
||||
const authEvent = createAuthEvent(relayUrl, challenge, privateKeyHex);
|
||||
|
||||
// Send AUTH response
|
||||
ws.send(JSON.stringify(['AUTH', authEvent]));
|
||||
} else if (messageType === 'OK') {
|
||||
// Check if this is the AUTH response
|
||||
const success = message[2];
|
||||
const msg = message[3] || '';
|
||||
|
||||
if (success) {
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
ws,
|
||||
url: relayUrl,
|
||||
authenticated: true,
|
||||
pubkey,
|
||||
});
|
||||
} else {
|
||||
console.error(`Auth failed for ${relayUrl}: ${msg}`);
|
||||
clearTimeout(timeout);
|
||||
ws.close();
|
||||
resolve(null);
|
||||
}
|
||||
} else if (messageType === 'NOTICE') {
|
||||
// Some relays don't require auth - connection is ready
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
ws,
|
||||
url: relayUrl,
|
||||
authenticated: false,
|
||||
pubkey,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
|
||||
// For relays that don't send AUTH challenge, resolve after short delay
|
||||
setTimeout(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
ws,
|
||||
url: relayUrl,
|
||||
authenticated: false, // No auth was required
|
||||
pubkey,
|
||||
});
|
||||
}
|
||||
}, 2000); // Wait 2 seconds for potential AUTH challenge
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an event to a relay with NIP-42 authentication support
|
||||
*
|
||||
* This function handles the complete flow:
|
||||
* 1. Connect to relay
|
||||
* 2. Handle AUTH challenge if sent
|
||||
* 3. Publish the event
|
||||
* 4. Wait for OK response
|
||||
* 5. Close connection
|
||||
*
|
||||
* @param relayUrl - The relay WebSocket URL
|
||||
* @param signedEvent - The already-signed Nostr event to publish
|
||||
* @param privateKeyHex - Private key for AUTH (if required)
|
||||
* @param timeoutMs - Timeout for the entire operation
|
||||
* @returns Promise resolving to publish result
|
||||
*/
|
||||
export async function publishEventWithAuth(
|
||||
relayUrl: string,
|
||||
signedEvent: ReturnType<typeof finalizeEvent>,
|
||||
privateKeyHex: string,
|
||||
timeoutMs = 15000
|
||||
): Promise<PublishResult> {
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.close();
|
||||
}
|
||||
resolve({
|
||||
relay: relayUrl,
|
||||
success: false,
|
||||
message: 'Timeout',
|
||||
});
|
||||
}, timeoutMs);
|
||||
|
||||
let ws: WebSocket;
|
||||
let authenticated = false;
|
||||
let eventSent = false;
|
||||
|
||||
try {
|
||||
ws = new WebSocket(relayUrl);
|
||||
} catch (e) {
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
relay: relayUrl,
|
||||
success: false,
|
||||
message: `Connection failed: ${e}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const sendEvent = () => {
|
||||
if (!eventSent && ws.readyState === WebSocket.OPEN) {
|
||||
eventSent = true;
|
||||
ws.send(JSON.stringify(['EVENT', signedEvent]));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
// Wait a moment for potential AUTH challenge before sending event
|
||||
setTimeout(() => {
|
||||
if (!authenticated) {
|
||||
// No auth challenge received, try sending event directly
|
||||
sendEvent();
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
const messageType = message[0];
|
||||
|
||||
if (messageType === 'AUTH') {
|
||||
// Relay requires authentication
|
||||
const challenge = message[1];
|
||||
const authEvent = createAuthEvent(relayUrl, challenge, privateKeyHex);
|
||||
ws.send(JSON.stringify(['AUTH', authEvent]));
|
||||
authenticated = true;
|
||||
} else if (messageType === 'OK') {
|
||||
const eventId = message[1];
|
||||
const success = message[2];
|
||||
const msg = message[3] || '';
|
||||
|
||||
// Check if this is our event or AUTH response
|
||||
if (eventId === signedEvent.id) {
|
||||
// This is the response to our published event
|
||||
clearTimeout(timeout);
|
||||
ws.close();
|
||||
|
||||
if (success) {
|
||||
resolve({
|
||||
relay: relayUrl,
|
||||
success: true,
|
||||
message: 'Published successfully',
|
||||
});
|
||||
} else {
|
||||
// Check if we need to retry after auth
|
||||
if (msg.includes('auth-required') && !authenticated) {
|
||||
// Relay requires auth but didn't send challenge
|
||||
// This shouldn't normally happen
|
||||
resolve({
|
||||
relay: relayUrl,
|
||||
success: false,
|
||||
message: 'Auth required but no challenge received',
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
relay: relayUrl,
|
||||
success: false,
|
||||
message: msg || 'Publish rejected',
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (authenticated && !eventSent) {
|
||||
// This is the OK response to our AUTH
|
||||
if (success) {
|
||||
// Auth succeeded, now send the event
|
||||
sendEvent();
|
||||
} else {
|
||||
clearTimeout(timeout);
|
||||
ws.close();
|
||||
resolve({
|
||||
relay: relayUrl,
|
||||
success: false,
|
||||
message: `Authentication failed: ${msg}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (messageType === 'NOTICE') {
|
||||
// Log notices but don't fail
|
||||
console.log(`Relay ${relayUrl} notice: ${message[1]}`);
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
relay: relayUrl,
|
||||
success: false,
|
||||
message: 'Connection error',
|
||||
});
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
// If we haven't resolved yet, treat as failure
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an event to multiple relays with NIP-42 support
|
||||
*
|
||||
* @param relayUrls - Array of relay WebSocket URLs
|
||||
* @param signedEvent - The already-signed Nostr event to publish
|
||||
* @param privateKeyHex - Private key for AUTH (if required)
|
||||
* @returns Promise resolving to array of publish results
|
||||
*/
|
||||
export async function publishToRelaysWithAuth(
|
||||
relayUrls: string[],
|
||||
signedEvent: ReturnType<typeof finalizeEvent>,
|
||||
privateKeyHex: string
|
||||
): Promise<PublishResult[]> {
|
||||
const results = await Promise.all(
|
||||
relayUrls.map((url) => publishEventWithAuth(url, signedEvent, privateKeyHex))
|
||||
);
|
||||
return results;
|
||||
}
|
||||
@@ -1,22 +1,424 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
declare const chrome: any;
|
||||
|
||||
export type LogCategory =
|
||||
| 'nip07'
|
||||
| 'permission'
|
||||
| 'vault'
|
||||
| 'profile'
|
||||
| 'bookmark'
|
||||
| 'system';
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: Date;
|
||||
level: 'log' | 'warn' | 'error' | 'debug';
|
||||
category: LogCategory;
|
||||
icon: string;
|
||||
message: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
// Serializable format for storage
|
||||
interface StoredLogEntry {
|
||||
timestamp: string;
|
||||
level: 'log' | 'warn' | 'error' | 'debug';
|
||||
category: LogCategory;
|
||||
icon: string;
|
||||
message: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
const LOGS_STORAGE_KEY = 'extensionLogs';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class LoggerService {
|
||||
#namespace: string | undefined;
|
||||
#logs: LogEntry[] = [];
|
||||
#maxLogs = 500;
|
||||
|
||||
initialize(namespace: string): void {
|
||||
this.#namespace = namespace;
|
||||
get logs(): LogEntry[] {
|
||||
return this.#logs;
|
||||
}
|
||||
|
||||
log(value: any) {
|
||||
async initialize(namespace: string): Promise<void> {
|
||||
this.#namespace = namespace;
|
||||
await this.#loadLogsFromStorage();
|
||||
}
|
||||
|
||||
async #loadLogsFromStorage(): Promise<void> {
|
||||
try {
|
||||
if (typeof chrome !== 'undefined' && chrome.storage?.session) {
|
||||
const result = await chrome.storage.session.get(LOGS_STORAGE_KEY);
|
||||
if (result[LOGS_STORAGE_KEY]) {
|
||||
// Convert stored format back to LogEntry with Date objects
|
||||
this.#logs = (result[LOGS_STORAGE_KEY] as StoredLogEntry[]).map(
|
||||
(entry) => ({
|
||||
...entry,
|
||||
timestamp: new Date(entry.timestamp),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load logs from storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async #saveLogsToStorage(): Promise<void> {
|
||||
try {
|
||||
if (typeof chrome !== 'undefined' && chrome.storage?.session) {
|
||||
// Convert Date to ISO string for storage
|
||||
const storedLogs: StoredLogEntry[] = this.#logs.map((entry) => ({
|
||||
...entry,
|
||||
timestamp: entry.timestamp.toISOString(),
|
||||
}));
|
||||
await chrome.storage.session.set({ [LOGS_STORAGE_KEY]: storedLogs });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save logs to storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshLogs(): Promise<void> {
|
||||
await this.#loadLogsFromStorage();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Generic logging methods
|
||||
// ============================================
|
||||
|
||||
log(value: any, data?: any) {
|
||||
this.#assureInitialized();
|
||||
this.#addLog('log', 'system', '📝', value, data);
|
||||
this.#consoleLog('log', value);
|
||||
}
|
||||
|
||||
warn(value: any, data?: any) {
|
||||
this.#assureInitialized();
|
||||
this.#addLog('warn', 'system', '⚠️', value, data);
|
||||
this.#consoleLog('warn', value);
|
||||
}
|
||||
|
||||
error(value: any, data?: any) {
|
||||
this.#assureInitialized();
|
||||
this.#addLog('error', 'system', '❌', value, data);
|
||||
this.#consoleLog('error', value);
|
||||
}
|
||||
|
||||
debug(value: any, data?: any) {
|
||||
this.#assureInitialized();
|
||||
this.#addLog('debug', 'system', '🔍', value, data);
|
||||
this.#consoleLog('debug', value);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// NIP-07 Action Logging
|
||||
// ============================================
|
||||
|
||||
logNip07Action(
|
||||
method: string,
|
||||
host: string,
|
||||
approved: boolean,
|
||||
autoApproved: boolean,
|
||||
details?: { kind?: number; peerPubkey?: string }
|
||||
) {
|
||||
this.#assureInitialized();
|
||||
const approvalType = autoApproved ? 'auto-approved' : approved ? 'approved' : 'denied';
|
||||
const icon = approved ? '✅' : '🚫';
|
||||
|
||||
let message = `${method} from ${host} - ${approvalType}`;
|
||||
if (details?.kind !== undefined) {
|
||||
message += ` (kind: ${details.kind})`;
|
||||
}
|
||||
|
||||
this.#addLog('log', 'nip07', icon, message, {
|
||||
method,
|
||||
host,
|
||||
approved,
|
||||
autoApproved,
|
||||
...details,
|
||||
});
|
||||
this.#consoleLog('log', message);
|
||||
}
|
||||
|
||||
logNip07GetPublicKey(host: string, approved: boolean, autoApproved: boolean) {
|
||||
this.logNip07Action('getPublicKey', host, approved, autoApproved);
|
||||
}
|
||||
|
||||
logNip07SignEvent(
|
||||
host: string,
|
||||
kind: number,
|
||||
approved: boolean,
|
||||
autoApproved: boolean
|
||||
) {
|
||||
this.logNip07Action('signEvent', host, approved, autoApproved, { kind });
|
||||
}
|
||||
|
||||
logNip07Encrypt(
|
||||
method: 'nip04.encrypt' | 'nip44.encrypt',
|
||||
host: string,
|
||||
approved: boolean,
|
||||
autoApproved: boolean,
|
||||
peerPubkey?: string
|
||||
) {
|
||||
this.logNip07Action(method, host, approved, autoApproved, { peerPubkey });
|
||||
}
|
||||
|
||||
logNip07Decrypt(
|
||||
method: 'nip04.decrypt' | 'nip44.decrypt',
|
||||
host: string,
|
||||
approved: boolean,
|
||||
autoApproved: boolean,
|
||||
peerPubkey?: string
|
||||
) {
|
||||
this.logNip07Action(method, host, approved, autoApproved, { peerPubkey });
|
||||
}
|
||||
|
||||
logNip07GetRelays(host: string, approved: boolean, autoApproved: boolean) {
|
||||
this.logNip07Action('getRelays', host, approved, autoApproved);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Permission Logging
|
||||
// ============================================
|
||||
|
||||
logPermissionStored(
|
||||
host: string,
|
||||
method: string,
|
||||
policy: string,
|
||||
kind?: number
|
||||
) {
|
||||
this.#assureInitialized();
|
||||
const icon = policy === 'allow' ? '🔓' : '🔒';
|
||||
let message = `Permission stored: ${method} for ${host} - ${policy}`;
|
||||
if (kind !== undefined) {
|
||||
message += ` (kind: ${kind})`;
|
||||
}
|
||||
this.#addLog('log', 'permission', icon, message, { host, method, policy, kind });
|
||||
this.#consoleLog('log', message);
|
||||
}
|
||||
|
||||
logPermissionDeleted(host: string, method: string, kind?: number) {
|
||||
this.#assureInitialized();
|
||||
let message = `Permission deleted: ${method} for ${host}`;
|
||||
if (kind !== undefined) {
|
||||
message += ` (kind: ${kind})`;
|
||||
}
|
||||
this.#addLog('log', 'permission', '🗑️', message, { host, method, kind });
|
||||
this.#consoleLog('log', message);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Vault Operations Logging
|
||||
// ============================================
|
||||
|
||||
logVaultUnlock() {
|
||||
this.#assureInitialized();
|
||||
this.#addLog('log', 'vault', '🔓', 'Vault unlocked', undefined);
|
||||
this.#consoleLog('log', 'Vault unlocked');
|
||||
}
|
||||
|
||||
logVaultLock() {
|
||||
this.#assureInitialized();
|
||||
this.#addLog('log', 'vault', '🔒', 'Vault locked', undefined);
|
||||
this.#consoleLog('log', 'Vault locked');
|
||||
}
|
||||
|
||||
logVaultCreated() {
|
||||
this.#assureInitialized();
|
||||
this.#addLog('log', 'vault', '🆕', 'Vault created', undefined);
|
||||
this.#consoleLog('log', 'Vault created');
|
||||
}
|
||||
|
||||
logVaultExport(fileName: string) {
|
||||
this.#assureInitialized();
|
||||
this.#addLog('log', 'vault', '📤', `Vault exported: ${fileName}`, { fileName });
|
||||
this.#consoleLog('log', `Vault exported: ${fileName}`);
|
||||
}
|
||||
|
||||
logVaultImport(fileName: string) {
|
||||
this.#assureInitialized();
|
||||
this.#addLog('log', 'vault', '📥', `Vault imported: ${fileName}`, { fileName });
|
||||
this.#consoleLog('log', `Vault imported: ${fileName}`);
|
||||
}
|
||||
|
||||
logVaultReset() {
|
||||
this.#assureInitialized();
|
||||
this.#addLog('warn', 'vault', '🗑️', 'Extension reset', undefined);
|
||||
this.#consoleLog('warn', 'Extension reset');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Profile Operations Logging
|
||||
// ============================================
|
||||
|
||||
logProfileFetchError(pubkey: string, error: string) {
|
||||
this.#assureInitialized();
|
||||
const shortPubkey = pubkey.substring(0, 8) + '...';
|
||||
this.#addLog('error', 'profile', '👤', `Failed to fetch profile for ${shortPubkey}: ${error}`, {
|
||||
pubkey,
|
||||
error,
|
||||
});
|
||||
this.#consoleLog('error', `Failed to fetch profile for ${shortPubkey}: ${error}`);
|
||||
}
|
||||
|
||||
logProfileParseError(pubkey: string) {
|
||||
this.#assureInitialized();
|
||||
const shortPubkey = pubkey.substring(0, 8) + '...';
|
||||
this.#addLog('error', 'profile', '👤', `Failed to parse profile content for ${shortPubkey}`, {
|
||||
pubkey,
|
||||
});
|
||||
this.#consoleLog('error', `Failed to parse profile content for ${shortPubkey}`);
|
||||
}
|
||||
|
||||
logNip05ValidationError(nip05: string, error: string) {
|
||||
this.#assureInitialized();
|
||||
this.#addLog('error', 'profile', '🔗', `NIP-05 validation failed for ${nip05}: ${error}`, {
|
||||
nip05,
|
||||
error,
|
||||
});
|
||||
this.#consoleLog('error', `NIP-05 validation failed for ${nip05}: ${error}`);
|
||||
}
|
||||
|
||||
logNip05ValidationSuccess(nip05: string, pubkey: string) {
|
||||
this.#assureInitialized();
|
||||
const shortPubkey = pubkey.substring(0, 8) + '...';
|
||||
this.#addLog('log', 'profile', '✓', `NIP-05 verified: ${nip05} → ${shortPubkey}`, {
|
||||
nip05,
|
||||
pubkey,
|
||||
});
|
||||
this.#consoleLog('log', `NIP-05 verified: ${nip05} → ${shortPubkey}`);
|
||||
}
|
||||
|
||||
logProfileEdit(identityNick: string, field: string) {
|
||||
this.#assureInitialized();
|
||||
this.#addLog('log', 'profile', '✏️', `Profile edited: ${identityNick} - ${field}`, {
|
||||
identityNick,
|
||||
field,
|
||||
});
|
||||
this.#consoleLog('log', `Profile edited: ${identityNick} - ${field}`);
|
||||
}
|
||||
|
||||
logIdentityCreated(nick: string) {
|
||||
this.#assureInitialized();
|
||||
this.#addLog('log', 'profile', '🆕', `Identity created: ${nick}`, { nick });
|
||||
this.#consoleLog('log', `Identity created: ${nick}`);
|
||||
}
|
||||
|
||||
logIdentityDeleted(nick: string) {
|
||||
this.#assureInitialized();
|
||||
this.#addLog('warn', 'profile', '🗑️', `Identity deleted: ${nick}`, { nick });
|
||||
this.#consoleLog('warn', `Identity deleted: ${nick}`);
|
||||
}
|
||||
|
||||
logIdentitySelected(nick: string) {
|
||||
this.#assureInitialized();
|
||||
this.#addLog('log', 'profile', '👆', `Identity selected: ${nick}`, { nick });
|
||||
this.#consoleLog('log', `Identity selected: ${nick}`);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Bookmark Operations Logging
|
||||
// ============================================
|
||||
|
||||
logBookmarkAdded(url: string, title: string) {
|
||||
this.#assureInitialized();
|
||||
this.#addLog('log', 'bookmark', '🔖', `Bookmark added: ${title}`, { url, title });
|
||||
this.#consoleLog('log', `Bookmark added: ${title}`);
|
||||
}
|
||||
|
||||
logBookmarkRemoved(url: string, title: string) {
|
||||
this.#assureInitialized();
|
||||
this.#addLog('log', 'bookmark', '🗑️', `Bookmark removed: ${title}`, { url, title });
|
||||
this.#consoleLog('log', `Bookmark removed: ${title}`);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// System/Error Logging
|
||||
// ============================================
|
||||
|
||||
logRelayFetchError(identityNick: string, error: string) {
|
||||
this.#assureInitialized();
|
||||
this.#addLog('error', 'system', '📡', `Failed to fetch relays for ${identityNick}: ${error}`, {
|
||||
identityNick,
|
||||
error,
|
||||
});
|
||||
this.#consoleLog('error', `Failed to fetch relays for ${identityNick}: ${error}`);
|
||||
}
|
||||
|
||||
logStorageError(operation: string, error: string) {
|
||||
this.#assureInitialized();
|
||||
this.#addLog('error', 'system', '💾', `Storage error (${operation}): ${error}`, {
|
||||
operation,
|
||||
error,
|
||||
});
|
||||
this.#consoleLog('error', `Storage error (${operation}): ${error}`);
|
||||
}
|
||||
|
||||
logCryptoError(operation: string, error: string) {
|
||||
this.#assureInitialized();
|
||||
this.#addLog('error', 'system', '🔐', `Crypto error (${operation}): ${error}`, {
|
||||
operation,
|
||||
error,
|
||||
});
|
||||
this.#consoleLog('error', `Crypto error (${operation}): ${error}`);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Internal methods
|
||||
// ============================================
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.#logs = [];
|
||||
await this.#saveLogsToStorage();
|
||||
}
|
||||
|
||||
#addLog(
|
||||
level: LogEntry['level'],
|
||||
category: LogCategory,
|
||||
icon: string,
|
||||
message: any,
|
||||
data?: any
|
||||
) {
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date(),
|
||||
level,
|
||||
category,
|
||||
icon,
|
||||
message: typeof message === 'string' ? message : JSON.stringify(message),
|
||||
data,
|
||||
};
|
||||
this.#logs.unshift(entry);
|
||||
|
||||
// Limit stored logs
|
||||
if (this.#logs.length > this.#maxLogs) {
|
||||
this.#logs.pop();
|
||||
}
|
||||
|
||||
// Save to storage asynchronously (don't block)
|
||||
this.#saveLogsToStorage();
|
||||
}
|
||||
|
||||
#consoleLog(level: 'log' | 'warn' | 'error' | 'debug', message: string) {
|
||||
const nowString = new Date().toLocaleString();
|
||||
|
||||
console.log(`[${this.#namespace} - ${nowString}]`, JSON.stringify(value));
|
||||
const formattedMsg = `[${this.#namespace} - ${nowString}] ${message}`;
|
||||
switch (level) {
|
||||
case 'warn':
|
||||
console.warn(formattedMsg);
|
||||
break;
|
||||
case 'error':
|
||||
console.error(formattedMsg);
|
||||
break;
|
||||
case 'debug':
|
||||
console.debug(formattedMsg);
|
||||
break;
|
||||
default:
|
||||
console.log(formattedMsg);
|
||||
}
|
||||
}
|
||||
|
||||
#assureInitialized() {
|
||||
@@ -27,3 +429,87 @@ export class LoggerService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Standalone functions for background script
|
||||
// (Background script runs in different context without Angular DI)
|
||||
// ============================================
|
||||
|
||||
export async function backgroundLog(
|
||||
category: LogCategory,
|
||||
icon: string,
|
||||
level: LogEntry['level'],
|
||||
message: string,
|
||||
data?: any
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (typeof chrome === 'undefined' || !chrome.storage?.session) {
|
||||
console.log(`[Background] ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await chrome.storage.session.get(LOGS_STORAGE_KEY);
|
||||
const existingLogs: StoredLogEntry[] = result[LOGS_STORAGE_KEY] || [];
|
||||
|
||||
const newEntry: StoredLogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
category,
|
||||
icon,
|
||||
message,
|
||||
data,
|
||||
};
|
||||
|
||||
const updatedLogs = [newEntry, ...existingLogs].slice(0, 500);
|
||||
await chrome.storage.session.set({ [LOGS_STORAGE_KEY]: updatedLogs });
|
||||
} catch (error) {
|
||||
console.error('Failed to add background log:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function backgroundLogNip07Action(
|
||||
method: string,
|
||||
host: string,
|
||||
approved: boolean,
|
||||
autoApproved: boolean,
|
||||
details?: { kind?: number; peerPubkey?: string }
|
||||
): Promise<void> {
|
||||
const approvalType = autoApproved
|
||||
? 'auto-approved'
|
||||
: approved
|
||||
? 'approved'
|
||||
: 'denied';
|
||||
const icon = approved ? '✅' : '🚫';
|
||||
|
||||
let message = `${method} from ${host} - ${approvalType}`;
|
||||
if (details?.kind !== undefined) {
|
||||
message += ` (kind: ${details.kind})`;
|
||||
}
|
||||
|
||||
await backgroundLog('nip07', icon, 'log', message, {
|
||||
method,
|
||||
host,
|
||||
approved,
|
||||
autoApproved,
|
||||
...details,
|
||||
});
|
||||
}
|
||||
|
||||
export async function backgroundLogPermissionStored(
|
||||
host: string,
|
||||
method: string,
|
||||
policy: string,
|
||||
kind?: number
|
||||
): Promise<void> {
|
||||
const icon = policy === 'allow' ? '🔓' : '🔒';
|
||||
let message = `Permission stored: ${method} for ${host} - ${policy}`;
|
||||
if (kind !== undefined) {
|
||||
message += ` (kind: ${kind})`;
|
||||
}
|
||||
await backgroundLog('permission', icon, 'log', message, {
|
||||
host,
|
||||
method,
|
||||
policy,
|
||||
kind,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { SimplePool } from 'nostr-tools/pool';
|
||||
import { FALLBACK_PROFILE_RELAYS } from '../../constants/fallback-relays';
|
||||
import { ProfileMetadata, ProfileMetadataCache } from '../storage/types';
|
||||
import { LoggerService } from '../logger/logger.service';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
declare const chrome: any;
|
||||
@@ -14,6 +15,7 @@ const STORAGE_KEY = 'profileMetadataCache';
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ProfileMetadataService {
|
||||
readonly #logger = inject(LoggerService);
|
||||
#cache: ProfileMetadataCache = {};
|
||||
#pool: SimplePool | null = null;
|
||||
#fetchPromises = new Map<string, Promise<ProfileMetadata | null>>();
|
||||
@@ -52,7 +54,8 @@ export class ProfileMetadataService {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load profile cache from storage:', error);
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.#logger.logStorageError('load profile cache', errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +68,8 @@ export class ProfileMetadataService {
|
||||
await chrome.storage.session.set({ [STORAGE_KEY]: this.#cache });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save profile cache to storage:', error);
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.#logger.logStorageError('save profile cache', errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +213,7 @@ export class ProfileMetadataService {
|
||||
this.#cache[pubkey] = profile;
|
||||
results.set(pubkey, profile);
|
||||
} catch {
|
||||
console.error(`Failed to parse profile for ${pubkey}`);
|
||||
this.#logger.logProfileParseError(pubkey);
|
||||
results.set(pubkey, null);
|
||||
}
|
||||
}
|
||||
@@ -225,7 +229,8 @@ export class ProfileMetadataService {
|
||||
await this.#saveCacheToStorage();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch profiles:', error);
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.#logger.logProfileFetchError('multiple', errorMsg);
|
||||
// Set null for all unfetched pubkeys on error
|
||||
for (const pubkey of uncachedPubkeys) {
|
||||
if (!results.has(pubkey)) {
|
||||
@@ -283,11 +288,12 @@ export class ProfileMetadataService {
|
||||
|
||||
return profile;
|
||||
} catch {
|
||||
console.error(`Failed to parse profile content for ${pubkey}`);
|
||||
this.#logger.logProfileParseError(pubkey);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch profile for ${pubkey}:`, error);
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.#logger.logProfileFetchError(pubkey, errorMsg);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ export abstract class BrowserSessionHandler {
|
||||
this.#browserSessionData = JSON.parse(JSON.stringify(data));
|
||||
}
|
||||
|
||||
clearInMemoryData() {
|
||||
this.#browserSessionData = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the full data to the session data storage.
|
||||
*
|
||||
|
||||
@@ -166,10 +166,19 @@ export const encryptIdentity = async function (
|
||||
return encryptedIdentity;
|
||||
};
|
||||
|
||||
/**
|
||||
* Locked vault context for decryption during unlock
|
||||
* - v1 vaults use password (PBKDF2)
|
||||
* - v2 vaults use keyBase64 (pre-derived Argon2id key)
|
||||
*/
|
||||
export type LockedVaultContext =
|
||||
| { iv: string; password: string; keyBase64?: undefined }
|
||||
| { iv: string; keyBase64: string; password?: undefined };
|
||||
|
||||
export const decryptIdentities = async function (
|
||||
this: StorageService,
|
||||
identities: Identity_ENCRYPTED[],
|
||||
withLockedVault: { iv: string; password: string } | undefined = undefined
|
||||
withLockedVault: LockedVaultContext | undefined = undefined
|
||||
): Promise<Identity_DECRYPTED[]> {
|
||||
const decryptedIdentities: Identity_DECRYPTED[] = [];
|
||||
|
||||
@@ -188,7 +197,7 @@ export const decryptIdentities = async function (
|
||||
export const decryptIdentity = async function (
|
||||
this: StorageService,
|
||||
identity: Identity_ENCRYPTED,
|
||||
withLockedVault: { iv: string; password: string } | undefined = undefined
|
||||
withLockedVault: LockedVaultContext | undefined = undefined
|
||||
): Promise<Identity_DECRYPTED> {
|
||||
if (typeof withLockedVault === 'undefined') {
|
||||
const decryptedIdentity: Identity_DECRYPTED = {
|
||||
@@ -201,30 +210,62 @@ export const decryptIdentity = async function (
|
||||
return decryptedIdentity;
|
||||
}
|
||||
|
||||
// v2: Use pre-derived key
|
||||
if (withLockedVault.keyBase64) {
|
||||
const decryptedIdentity: Identity_DECRYPTED = {
|
||||
id: await this.decryptWithLockedVaultV2(
|
||||
identity.id,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
nick: await this.decryptWithLockedVaultV2(
|
||||
identity.nick,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
createdAt: await this.decryptWithLockedVaultV2(
|
||||
identity.createdAt,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
privkey: await this.decryptWithLockedVaultV2(
|
||||
identity.privkey,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
};
|
||||
return decryptedIdentity;
|
||||
}
|
||||
|
||||
// v1: Use password (PBKDF2)
|
||||
const decryptedIdentity: Identity_DECRYPTED = {
|
||||
id: await this.decryptWithLockedVault(
|
||||
identity.id,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
nick: await this.decryptWithLockedVault(
|
||||
identity.nick,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
createdAt: await this.decryptWithLockedVault(
|
||||
identity.createdAt,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
privkey: await this.decryptWithLockedVault(
|
||||
identity.privkey,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Permission_ENCRYPTED,
|
||||
StorageService,
|
||||
} from '@common';
|
||||
import { LockedVaultContext } from './identity';
|
||||
|
||||
export const deletePermission = async function (
|
||||
this: StorageService,
|
||||
@@ -32,7 +33,7 @@ export const deletePermission = async function (
|
||||
export const decryptPermission = async function (
|
||||
this: StorageService,
|
||||
permission: Permission_ENCRYPTED,
|
||||
withLockedVault: { iv: string; password: string } | undefined = undefined
|
||||
withLockedVault: LockedVaultContext | undefined = undefined
|
||||
): Promise<Permission_DECRYPTED> {
|
||||
if (typeof withLockedVault === 'undefined') {
|
||||
const decryptedPermission: Permission_DECRYPTED = {
|
||||
@@ -48,36 +49,82 @@ export const decryptPermission = async function (
|
||||
return decryptedPermission;
|
||||
}
|
||||
|
||||
// v2: Use pre-derived key
|
||||
if (withLockedVault.keyBase64) {
|
||||
const decryptedPermission: Permission_DECRYPTED = {
|
||||
id: await this.decryptWithLockedVaultV2(
|
||||
permission.id,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
identityId: await this.decryptWithLockedVaultV2(
|
||||
permission.identityId,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
method: await this.decryptWithLockedVaultV2(
|
||||
permission.method,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
methodPolicy: await this.decryptWithLockedVaultV2(
|
||||
permission.methodPolicy,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
host: await this.decryptWithLockedVaultV2(
|
||||
permission.host,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
};
|
||||
if (permission.kind) {
|
||||
decryptedPermission.kind = await this.decryptWithLockedVaultV2(
|
||||
permission.kind,
|
||||
'number',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
);
|
||||
}
|
||||
return decryptedPermission;
|
||||
}
|
||||
|
||||
// v1: Use password (PBKDF2)
|
||||
const decryptedPermission: Permission_DECRYPTED = {
|
||||
id: await this.decryptWithLockedVault(
|
||||
permission.id,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
identityId: await this.decryptWithLockedVault(
|
||||
permission.identityId,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
method: await this.decryptWithLockedVault(
|
||||
permission.method,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
methodPolicy: await this.decryptWithLockedVault(
|
||||
permission.methodPolicy,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
host: await this.decryptWithLockedVault(
|
||||
permission.host,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
};
|
||||
if (permission.kind) {
|
||||
@@ -85,7 +132,7 @@ export const decryptPermission = async function (
|
||||
permission.kind,
|
||||
'number',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
);
|
||||
}
|
||||
return decryptedPermission;
|
||||
@@ -94,17 +141,22 @@ export const decryptPermission = async function (
|
||||
export const decryptPermissions = async function (
|
||||
this: StorageService,
|
||||
permissions: Permission_ENCRYPTED[],
|
||||
withLockedVault: { iv: string; password: string } | undefined = undefined
|
||||
withLockedVault: LockedVaultContext | undefined = undefined
|
||||
): Promise<Permission_DECRYPTED[]> {
|
||||
const decryptedPermissions: Permission_DECRYPTED[] = [];
|
||||
|
||||
for (const permission of permissions) {
|
||||
const decryptedPermission = await decryptPermission.call(
|
||||
this,
|
||||
permission,
|
||||
withLockedVault
|
||||
);
|
||||
decryptedPermissions.push(decryptedPermission);
|
||||
try {
|
||||
const decryptedPermission = await decryptPermission.call(
|
||||
this,
|
||||
permission,
|
||||
withLockedVault
|
||||
);
|
||||
decryptedPermissions.push(decryptedPermission);
|
||||
} catch (error) {
|
||||
// Skip corrupted permissions (e.g., encrypted with wrong key)
|
||||
console.warn('[vault] Skipping corrupted permission:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return decryptedPermissions;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Relay_ENCRYPTED,
|
||||
StorageService,
|
||||
} from '@common';
|
||||
import { LockedVaultContext } from './identity';
|
||||
|
||||
export const addRelay = async function (
|
||||
this: StorageService,
|
||||
@@ -126,7 +127,7 @@ export const updateRelay = async function (
|
||||
export const decryptRelay = async function (
|
||||
this: StorageService,
|
||||
relay: Relay_ENCRYPTED,
|
||||
withLockedVault: { iv: string; password: string } | undefined = undefined
|
||||
withLockedVault: LockedVaultContext | undefined = undefined
|
||||
): Promise<Relay_DECRYPTED> {
|
||||
if (typeof withLockedVault === 'undefined') {
|
||||
const decryptedRelay: Relay_DECRYPTED = {
|
||||
@@ -139,36 +140,74 @@ export const decryptRelay = async function (
|
||||
return decryptedRelay;
|
||||
}
|
||||
|
||||
// v2: Use pre-derived key
|
||||
if (withLockedVault.keyBase64) {
|
||||
const decryptedRelay: Relay_DECRYPTED = {
|
||||
id: await this.decryptWithLockedVaultV2(
|
||||
relay.id,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
identityId: await this.decryptWithLockedVaultV2(
|
||||
relay.identityId,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
url: await this.decryptWithLockedVaultV2(
|
||||
relay.url,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
read: await this.decryptWithLockedVaultV2(
|
||||
relay.read,
|
||||
'boolean',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
write: await this.decryptWithLockedVaultV2(
|
||||
relay.write,
|
||||
'boolean',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.keyBase64
|
||||
),
|
||||
};
|
||||
return decryptedRelay;
|
||||
}
|
||||
|
||||
// v1: Use password (PBKDF2)
|
||||
const decryptedRelay: Relay_DECRYPTED = {
|
||||
id: await this.decryptWithLockedVault(
|
||||
relay.id,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
identityId: await this.decryptWithLockedVault(
|
||||
relay.identityId,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
url: await this.decryptWithLockedVault(
|
||||
relay.url,
|
||||
'string',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
read: await this.decryptWithLockedVault(
|
||||
relay.read,
|
||||
'boolean',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
write: await this.decryptWithLockedVault(
|
||||
relay.write,
|
||||
'boolean',
|
||||
withLockedVault.iv,
|
||||
withLockedVault.password
|
||||
withLockedVault.password!
|
||||
),
|
||||
};
|
||||
return decryptedRelay;
|
||||
@@ -177,7 +216,7 @@ export const decryptRelay = async function (
|
||||
export const decryptRelays = async function (
|
||||
this: StorageService,
|
||||
relays: Relay_ENCRYPTED[],
|
||||
withLockedVault: { iv: string; password: string } | undefined = undefined
|
||||
withLockedVault: LockedVaultContext | undefined = undefined
|
||||
): Promise<Relay_DECRYPTED[]> {
|
||||
const decryptedRelays: Relay_DECRYPTED[] = [];
|
||||
|
||||
|
||||
@@ -3,10 +3,14 @@ import {
|
||||
BrowserSyncData,
|
||||
CryptoHelper,
|
||||
StorageService,
|
||||
generateSalt,
|
||||
generateIV,
|
||||
deriveKeyArgon2,
|
||||
} from '@common';
|
||||
import { decryptIdentities } from './identity';
|
||||
import { Buffer } from 'buffer';
|
||||
import { decryptIdentities, encryptIdentity, LockedVaultContext } from './identity';
|
||||
import { decryptPermissions } from './permission';
|
||||
import { decryptRelays } from './relay';
|
||||
import { decryptRelays, encryptRelay } from './relay';
|
||||
|
||||
export const createNewVault = async function (
|
||||
this: StorageService,
|
||||
@@ -16,9 +20,17 @@ export const createNewVault = async function (
|
||||
|
||||
const vaultHash = await CryptoHelper.hash(password);
|
||||
|
||||
// v2: Generate random salt and derive key with Argon2id
|
||||
const salt = generateSalt();
|
||||
const iv = generateIV();
|
||||
const saltBytes = Buffer.from(salt, 'base64');
|
||||
const keyBytes = await deriveKeyArgon2(password, saltBytes);
|
||||
const vaultKey = Buffer.from(keyBytes).toString('base64');
|
||||
|
||||
const sessionData: BrowserSessionData = {
|
||||
iv: CryptoHelper.generateIV(),
|
||||
vaultPassword: password,
|
||||
iv,
|
||||
salt,
|
||||
vaultKey, // v2: Store pre-derived key instead of password
|
||||
identities: [],
|
||||
permissions: [],
|
||||
relays: [],
|
||||
@@ -29,7 +41,8 @@ export const createNewVault = async function (
|
||||
|
||||
const syncData: BrowserSyncData = {
|
||||
version: this.latestVersion,
|
||||
iv: sessionData.iv,
|
||||
salt, // v2: Random salt for Argon2id
|
||||
iv,
|
||||
vaultHash,
|
||||
identities: [],
|
||||
permissions: [],
|
||||
@@ -44,6 +57,7 @@ export const unlockVault = async function (
|
||||
password: string
|
||||
): Promise<void> {
|
||||
this.assureIsInitialized();
|
||||
console.log('[vault] Starting unlock...');
|
||||
|
||||
let browserSessionData = this.getBrowserSessionHandler().browserSessionData;
|
||||
if (browserSessionData) {
|
||||
@@ -59,55 +73,190 @@ export const unlockVault = async function (
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[vault] Checking password hash...');
|
||||
const passwordHash = await CryptoHelper.hash(password);
|
||||
if (passwordHash !== browserSyncData.vaultHash) {
|
||||
throw new Error('Invalid password.');
|
||||
}
|
||||
console.log('[vault] Password hash verified');
|
||||
|
||||
// Ok. Everything is fine. We can unlock the vault now.
|
||||
// Detect vault version
|
||||
const isV2 = !!browserSyncData.salt;
|
||||
console.log('[vault] Vault version:', isV2 ? 'v2' : 'v1');
|
||||
|
||||
// Decrypt the identities.
|
||||
const withLockedVault = {
|
||||
iv: browserSyncData.iv,
|
||||
password,
|
||||
};
|
||||
let withLockedVault: LockedVaultContext;
|
||||
let vaultKey: string | undefined;
|
||||
let vaultPassword: string | undefined;
|
||||
|
||||
if (isV2) {
|
||||
// v2: Derive key with Argon2id (~3 seconds)
|
||||
console.log('[vault] Deriving key with Argon2id...');
|
||||
const saltBytes = Buffer.from(browserSyncData.salt!, 'base64');
|
||||
const keyBytes = await deriveKeyArgon2(password, saltBytes);
|
||||
console.log('[vault] Key derived, length:', keyBytes.length);
|
||||
vaultKey = Buffer.from(keyBytes).toString('base64');
|
||||
withLockedVault = {
|
||||
iv: browserSyncData.iv,
|
||||
keyBase64: vaultKey,
|
||||
};
|
||||
} else {
|
||||
// v1: Use password with PBKDF2
|
||||
vaultPassword = password;
|
||||
withLockedVault = {
|
||||
iv: browserSyncData.iv,
|
||||
password,
|
||||
};
|
||||
}
|
||||
|
||||
// Decrypt the data
|
||||
console.log('[vault] Decrypting identities...');
|
||||
const decryptedIdentities = await decryptIdentities.call(
|
||||
this,
|
||||
browserSyncData.identities,
|
||||
withLockedVault
|
||||
);
|
||||
console.log('[vault] Decrypted', decryptedIdentities.length, 'identities');
|
||||
|
||||
console.log('[vault] Decrypting permissions...');
|
||||
const decryptedPermissions = await decryptPermissions.call(
|
||||
this,
|
||||
browserSyncData.permissions,
|
||||
withLockedVault
|
||||
);
|
||||
console.log('[vault] Decrypted', decryptedPermissions.length, 'permissions');
|
||||
|
||||
console.log('[vault] Decrypting relays...');
|
||||
const decryptedRelays = await decryptRelays.call(
|
||||
this,
|
||||
browserSyncData.relays,
|
||||
withLockedVault
|
||||
);
|
||||
const decryptedSelectedIdentityId =
|
||||
browserSyncData.selectedIdentityId === null
|
||||
? null
|
||||
: await this.decryptWithLockedVault(
|
||||
browserSyncData.selectedIdentityId,
|
||||
'string',
|
||||
browserSyncData.iv,
|
||||
password
|
||||
);
|
||||
console.log('[vault] Decrypted', decryptedRelays.length, 'relays');
|
||||
|
||||
console.log('[vault] Decrypting selectedIdentityId...');
|
||||
let decryptedSelectedIdentityId: string | null = null;
|
||||
if (browserSyncData.selectedIdentityId !== null) {
|
||||
if (isV2) {
|
||||
decryptedSelectedIdentityId = await this.decryptWithLockedVaultV2(
|
||||
browserSyncData.selectedIdentityId,
|
||||
'string',
|
||||
browserSyncData.iv,
|
||||
vaultKey!
|
||||
);
|
||||
} else {
|
||||
decryptedSelectedIdentityId = await this.decryptWithLockedVault(
|
||||
browserSyncData.selectedIdentityId,
|
||||
'string',
|
||||
browserSyncData.iv,
|
||||
password
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log('[vault] selectedIdentityId:', decryptedSelectedIdentityId);
|
||||
|
||||
browserSessionData = {
|
||||
vaultPassword: password,
|
||||
vaultPassword: isV2 ? undefined : vaultPassword,
|
||||
vaultKey: isV2 ? vaultKey : undefined,
|
||||
iv: browserSyncData.iv,
|
||||
salt: browserSyncData.salt,
|
||||
permissions: decryptedPermissions,
|
||||
identities: decryptedIdentities,
|
||||
selectedIdentityId: decryptedSelectedIdentityId,
|
||||
relays: decryptedRelays,
|
||||
};
|
||||
|
||||
console.log('[vault] Saving session data...');
|
||||
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
|
||||
this.getBrowserSessionHandler().setFullData(browserSessionData);
|
||||
console.log('[vault] Session data saved');
|
||||
|
||||
// Auto-migrate v1 to v2 after successful unlock
|
||||
if (!isV2) {
|
||||
console.log('[vault] Migrating v1 to v2...');
|
||||
await migrateVaultV1ToV2.call(this, password);
|
||||
console.log('[vault] Migration complete');
|
||||
}
|
||||
|
||||
console.log('[vault] Unlock complete!');
|
||||
};
|
||||
|
||||
/**
|
||||
* Migrate a v1 vault (PBKDF2) to v2 (Argon2id)
|
||||
* Called automatically after successful v1 unlock
|
||||
*/
|
||||
async function migrateVaultV1ToV2(
|
||||
this: StorageService,
|
||||
password: string
|
||||
): Promise<void> {
|
||||
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
|
||||
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
|
||||
if (!browserSyncData || !browserSessionData) {
|
||||
throw new Error('Cannot migrate: data not available');
|
||||
}
|
||||
|
||||
// Generate new salt and derive Argon2id key
|
||||
const newSalt = generateSalt();
|
||||
const newIv = generateIV();
|
||||
const saltBytes = Buffer.from(newSalt, 'base64');
|
||||
const keyBytes = await deriveKeyArgon2(password, saltBytes);
|
||||
const vaultKey = Buffer.from(keyBytes).toString('base64');
|
||||
|
||||
// Update session data with new v2 credentials
|
||||
browserSessionData.salt = newSalt;
|
||||
browserSessionData.iv = newIv;
|
||||
browserSessionData.vaultKey = vaultKey;
|
||||
browserSessionData.vaultPassword = undefined; // Remove v1 password
|
||||
|
||||
// Re-encrypt all data with new v2 key
|
||||
const encryptedIdentities = [];
|
||||
for (const identity of browserSessionData.identities) {
|
||||
const encrypted = await encryptIdentity.call(this, identity);
|
||||
encryptedIdentities.push(encrypted);
|
||||
}
|
||||
|
||||
const encryptedRelays = [];
|
||||
for (const relay of browserSessionData.relays) {
|
||||
const encrypted = await encryptRelay.call(this, relay);
|
||||
encryptedRelays.push(encrypted);
|
||||
}
|
||||
|
||||
// For permissions, we need to re-encrypt them too
|
||||
const encryptedPermissions = [];
|
||||
for (const permission of browserSessionData.permissions) {
|
||||
const encryptedPermission = {
|
||||
id: await this.encrypt(permission.id),
|
||||
identityId: await this.encrypt(permission.identityId),
|
||||
host: await this.encrypt(permission.host),
|
||||
method: await this.encrypt(permission.method),
|
||||
methodPolicy: await this.encrypt(permission.methodPolicy),
|
||||
kind: permission.kind !== undefined ? await this.encrypt(permission.kind.toString()) : undefined,
|
||||
};
|
||||
encryptedPermissions.push(encryptedPermission);
|
||||
}
|
||||
|
||||
const encryptedSelectedIdentityId = browserSessionData.selectedIdentityId
|
||||
? await this.encrypt(browserSessionData.selectedIdentityId)
|
||||
: null;
|
||||
|
||||
// Update sync data with v2 format
|
||||
const migratedSyncData: BrowserSyncData = {
|
||||
version: this.latestVersion,
|
||||
salt: newSalt,
|
||||
iv: newIv,
|
||||
vaultHash: browserSyncData.vaultHash, // Keep same password hash
|
||||
identities: encryptedIdentities,
|
||||
permissions: encryptedPermissions,
|
||||
relays: encryptedRelays,
|
||||
selectedIdentityId: encryptedSelectedIdentityId,
|
||||
};
|
||||
|
||||
// Save migrated data
|
||||
await this.getBrowserSyncHandler().saveAndSetFullData(migratedSyncData);
|
||||
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
|
||||
|
||||
console.log('Vault migrated from v1 (PBKDF2) to v2 (Argon2id)');
|
||||
}
|
||||
|
||||
export const deleteVault = async function (
|
||||
this: StorageService,
|
||||
doNotSetIsInitializedToFalse: boolean
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { BrowserSyncFlow, SignerMetaData } from './types';
|
||||
import { Bookmark, BrowserSyncFlow, SignerMetaData } from './types';
|
||||
|
||||
export abstract class SignerMetaHandler {
|
||||
get signerMetaData(): SignerMetaData | undefined {
|
||||
@@ -8,7 +8,7 @@ export abstract class SignerMetaHandler {
|
||||
|
||||
#signerMetaData?: SignerMetaData;
|
||||
|
||||
readonly metaProperties = ['syncFlow', 'vaultSnapshots', 'recklessMode', 'whitelistedHosts'];
|
||||
readonly metaProperties = ['syncFlow', 'vaultSnapshots', 'recklessMode', 'whitelistedHosts', 'bookmarks'];
|
||||
/**
|
||||
* Load the full data from the storage. If the storage is used for storing
|
||||
* other data (e.g. browser sync data when the user decided to NOT sync),
|
||||
@@ -89,4 +89,26 @@ export abstract class SignerMetaHandler {
|
||||
|
||||
await this.saveFullData(this.#signerMetaData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the bookmarks array and immediately saves it.
|
||||
*/
|
||||
async setBookmarks(bookmarks: Bookmark[]): Promise<void> {
|
||||
if (!this.#signerMetaData) {
|
||||
this.#signerMetaData = {
|
||||
bookmarks,
|
||||
};
|
||||
} else {
|
||||
this.#signerMetaData.bookmarks = bookmarks;
|
||||
}
|
||||
|
||||
await this.saveFullData(this.#signerMetaData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current bookmarks.
|
||||
*/
|
||||
getBookmarks(): Bookmark[] {
|
||||
return this.#signerMetaData?.bookmarks ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from './types';
|
||||
import { SignerMetaHandler } from './signer-meta-handler';
|
||||
import { CryptoHelper } from '@common';
|
||||
import { Buffer } from 'buffer';
|
||||
import {
|
||||
addIdentity,
|
||||
deleteIdentity,
|
||||
@@ -31,7 +32,7 @@ export interface StorageServiceConfig {
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class StorageService {
|
||||
readonly latestVersion = 1;
|
||||
readonly latestVersion = 2;
|
||||
isInitialized = false;
|
||||
|
||||
#browserSessionHandler!: BrowserSessionHandler;
|
||||
@@ -123,6 +124,14 @@ export class StorageService {
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
async lockVault(): Promise<void> {
|
||||
this.assureIsInitialized();
|
||||
await this.getBrowserSessionHandler().clearData();
|
||||
this.getBrowserSessionHandler().clearInMemoryData();
|
||||
// Note: We don't set isInitialized = false here because the sync data
|
||||
// (encrypted vault) is still loaded and we need it to unlock again
|
||||
}
|
||||
|
||||
async unlockVault(password: string): Promise<void> {
|
||||
await unlockVault.call(this, password);
|
||||
}
|
||||
@@ -231,10 +240,19 @@ export class StorageService {
|
||||
async encrypt(value: string): Promise<string> {
|
||||
const browserSessionData =
|
||||
this.getBrowserSessionHandler().browserSessionData;
|
||||
if (!browserSessionData || !browserSessionData.vaultPassword) {
|
||||
if (!browserSessionData) {
|
||||
throw new Error('Browser session data is undefined.');
|
||||
}
|
||||
|
||||
// v2: Use pre-derived key directly with AES-GCM
|
||||
if (browserSessionData.vaultKey) {
|
||||
return this.encryptV2(value, browserSessionData.iv, browserSessionData.vaultKey);
|
||||
}
|
||||
|
||||
// v1: Use PBKDF2 with password
|
||||
if (!browserSessionData.vaultPassword) {
|
||||
throw new Error('No vault password or key available.');
|
||||
}
|
||||
return CryptoHelper.encrypt(
|
||||
value,
|
||||
browserSessionData.iv,
|
||||
@@ -242,16 +260,54 @@ export class StorageService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 encryption: Use pre-derived key bytes directly with AES-GCM (no key derivation)
|
||||
*/
|
||||
async encryptV2(text: string, ivBase64: string, keyBase64: string): Promise<string> {
|
||||
const keyBytes = Buffer.from(keyBase64, 'base64');
|
||||
const iv = Buffer.from(ivBase64, 'base64');
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt']
|
||||
);
|
||||
|
||||
const cipherText = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
key,
|
||||
new TextEncoder().encode(text)
|
||||
);
|
||||
|
||||
return Buffer.from(cipherText).toString('base64');
|
||||
}
|
||||
|
||||
async decrypt(
|
||||
value: string,
|
||||
returnType: 'string' | 'number' | 'boolean'
|
||||
): Promise<any> {
|
||||
const browserSessionData =
|
||||
this.getBrowserSessionHandler().browserSessionData;
|
||||
if (!browserSessionData || !browserSessionData.vaultPassword) {
|
||||
if (!browserSessionData) {
|
||||
throw new Error('Browser session data is undefined.');
|
||||
}
|
||||
|
||||
// v2: Use pre-derived key directly with AES-GCM
|
||||
if (browserSessionData.vaultKey) {
|
||||
const decryptedValue = await this.decryptV2(
|
||||
value,
|
||||
browserSessionData.iv,
|
||||
browserSessionData.vaultKey
|
||||
);
|
||||
return this.parseDecryptedValue(decryptedValue, returnType);
|
||||
}
|
||||
|
||||
// v1: Use PBKDF2 with password
|
||||
if (!browserSessionData.vaultPassword) {
|
||||
throw new Error('No vault password or key available.');
|
||||
}
|
||||
return this.decryptWithLockedVault(
|
||||
value,
|
||||
returnType,
|
||||
@@ -260,6 +316,52 @@ export class StorageService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 decryption: Use pre-derived key bytes directly with AES-GCM (no key derivation)
|
||||
*/
|
||||
async decryptV2(encryptedBase64: string, ivBase64: string, keyBase64: string): Promise<string> {
|
||||
const keyBytes = Buffer.from(keyBase64, 'base64');
|
||||
const iv = Buffer.from(ivBase64, 'base64');
|
||||
const cipherText = Buffer.from(encryptedBase64, 'base64');
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
key,
|
||||
cipherText
|
||||
);
|
||||
|
||||
return new TextDecoder().decode(decrypted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a decrypted string value into the desired type
|
||||
*/
|
||||
private parseDecryptedValue(
|
||||
decryptedValue: string,
|
||||
returnType: 'string' | 'number' | 'boolean'
|
||||
): any {
|
||||
switch (returnType) {
|
||||
case 'number':
|
||||
return parseInt(decryptedValue);
|
||||
case 'boolean':
|
||||
return decryptedValue === 'true';
|
||||
case 'string':
|
||||
default:
|
||||
return decryptedValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1: Decrypt with locked vault using password (PBKDF2)
|
||||
*/
|
||||
async decryptWithLockedVault(
|
||||
value: string,
|
||||
returnType: 'string' | 'number' | 'boolean',
|
||||
@@ -267,18 +369,20 @@ export class StorageService {
|
||||
password: string
|
||||
): Promise<any> {
|
||||
const decryptedValue = await CryptoHelper.decrypt(value, iv, password);
|
||||
return this.parseDecryptedValue(decryptedValue, returnType);
|
||||
}
|
||||
|
||||
switch (returnType) {
|
||||
case 'number':
|
||||
return parseInt(decryptedValue);
|
||||
|
||||
case 'boolean':
|
||||
return decryptedValue === 'true';
|
||||
|
||||
case 'string':
|
||||
default:
|
||||
return decryptedValue;
|
||||
}
|
||||
/**
|
||||
* v2: Decrypt with locked vault using pre-derived key (Argon2id)
|
||||
*/
|
||||
async decryptWithLockedVaultV2(
|
||||
value: string,
|
||||
returnType: 'string' | 'number' | 'boolean',
|
||||
iv: string,
|
||||
keyBase64: string
|
||||
): Promise<any> {
|
||||
const decryptedValue = await this.decryptV2(value, iv, keyBase64);
|
||||
return this.parseDecryptedValue(decryptedValue, returnType);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,6 +47,9 @@ export interface BrowserSyncData_PART_Unencrypted {
|
||||
version: number;
|
||||
iv: string;
|
||||
vaultHash: string;
|
||||
// Version 2+: Random 32-byte salt for Argon2id key derivation (base64)
|
||||
// Version 1: Not present (uses PBKDF2 with hardcoded salt)
|
||||
salt?: string;
|
||||
}
|
||||
|
||||
export interface BrowserSyncData_PART_Encrypted {
|
||||
@@ -69,10 +72,13 @@ export enum BrowserSyncFlow {
|
||||
export interface BrowserSessionData {
|
||||
// The following properties purely come from the browser session storage
|
||||
// and will never be going into the browser sync storage.
|
||||
vaultPassword?: string;
|
||||
vaultPassword?: string; // v1 only: raw password for PBKDF2
|
||||
vaultKey?: string; // v2+: pre-derived key bytes (base64) from Argon2id
|
||||
|
||||
// The following properties initially come from the browser sync storage.
|
||||
iv: string;
|
||||
// Version 2+: Random salt for Argon2id (base64)
|
||||
salt?: string;
|
||||
permissions: Permission_DECRYPTED[];
|
||||
identities: Identity_DECRYPTED[];
|
||||
selectedIdentityId: string | null;
|
||||
@@ -88,6 +94,16 @@ export const SIGNER_META_DATA_KEY = {
|
||||
vaultSnapshots: 'vaultSnapshots',
|
||||
};
|
||||
|
||||
/**
|
||||
* Bookmark entry for storing user bookmarks
|
||||
*/
|
||||
export interface Bookmark {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface SignerMetaData {
|
||||
syncFlow?: number; // 0 = no sync, 1 = browser sync, (future: 2 = Signer sync, 3 = Custom sync (bring your own sync))
|
||||
|
||||
@@ -98,6 +114,9 @@ export interface SignerMetaData {
|
||||
|
||||
// Whitelisted hosts: auto-approve all actions from these hosts
|
||||
whitelistedHosts?: string[];
|
||||
|
||||
// User bookmarks
|
||||
bookmarks?: Bookmark[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
.sam-text-header {
|
||||
background: var(--background);
|
||||
z-index: 20;
|
||||
padding-top: var(--size);
|
||||
padding-bottom: var(--size);
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
|
||||
span {
|
||||
font-family: var(--font-heading);
|
||||
@@ -14,6 +15,28 @@
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1rem;
|
||||
}
|
||||
|
||||
.lock-btn {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background-light);
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sam-footer-grid-2 {
|
||||
|
||||
@@ -84,3 +84,11 @@ h2.font-heading {
|
||||
h3.font-heading {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
// Emoji styling
|
||||
.emoji {
|
||||
font-family: var(--font-emoji);
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
--font-sans: 'IBM Plex Mono', monospace;
|
||||
--font-heading: 'reglisse', sans-serif;
|
||||
--font-theylive: 'theylive', sans-serif;
|
||||
--font-emoji: 'Noto Color Emoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Android Emoji', sans-serif;
|
||||
|
||||
// Border radius (from market)
|
||||
--radius: 0.25rem;
|
||||
|
||||
@@ -10,9 +10,12 @@ export * from './lib/constants/fallback-relays';
|
||||
|
||||
// Helpers
|
||||
export * from './lib/helpers/crypto-helper';
|
||||
export * from './lib/helpers/argon2-crypto';
|
||||
export * from './lib/helpers/nostr-helper';
|
||||
export * from './lib/helpers/text-helper';
|
||||
export * from './lib/helpers/date-helper';
|
||||
export * from './lib/helpers/websocket-auth';
|
||||
export * from './lib/helpers/nip05-validator';
|
||||
|
||||
// Models
|
||||
export * from './lib/models/nostr';
|
||||
@@ -35,6 +38,7 @@ export * from './lib/components/toast/toast.component';
|
||||
export * from './lib/components/nav-item/nav-item.component';
|
||||
export * from './lib/components/pubkey/pubkey.component';
|
||||
export * from './lib/components/relay-rw/relay-rw.component';
|
||||
export * from './lib/components/deriving-modal/deriving-modal.component';
|
||||
|
||||
// Pipes
|
||||
export * from './lib/pipes/visual-relay.pipe';
|
||||
|
||||
3
projects/firefox/public/edit.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.06 9L15 9.94L5.92 19H5V18.08L14.06 9ZM17.66 3C17.41 3 17.15 3.1 16.96 3.29L15.13 5.12L18.88 8.87L20.71 7.04C21.1 6.65 21.1 6 20.71 5.63L18.37 3.29C18.17 3.09 17.92 3 17.66 3ZM14.06 6.19L3 17.25V21H6.75L17.81 9.94L14.06 6.19Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 364 B |
@@ -2,12 +2,15 @@
|
||||
"manifest_version": 3,
|
||||
"name": "Plebeian Signer",
|
||||
"description": "Nostr Identity Manager & Signer",
|
||||
"version": "0.0.9",
|
||||
"version": "1.0.8",
|
||||
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
|
||||
"options_page": "options.html",
|
||||
"permissions": [
|
||||
"storage"
|
||||
],
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "index.html",
|
||||
"default_icon": {
|
||||
|
||||
|
Before Width: | Height: | Size: 983 B After Width: | Height: | Size: 983 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -226,7 +226,7 @@
|
||||
<!------------->
|
||||
<div class="sam-footer-grid-2">
|
||||
<div class="btn-group">
|
||||
<button id="rejectButton" type="button" class="btn btn-secondary">
|
||||
<button id="rejectOnceButton" type="button" class="btn btn-secondary">
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
@@ -239,16 +239,16 @@
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<button id="rejectJustOnceButton" class="dropdown-item">
|
||||
just once
|
||||
<button id="rejectAlwaysButton" class="dropdown-item">
|
||||
Reject Always
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button id="approveButton" type="button" class="btn btn-primary">
|
||||
Approve
|
||||
<button id="approveAlwaysButton" type="button" class="btn btn-primary">
|
||||
Approve Always
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -260,8 +260,8 @@
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<button id="approveJustOnceButton" class="dropdown-item" href="#">
|
||||
just once
|
||||
<button id="approveOnceButton" class="dropdown-item">
|
||||
Approve Once
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -6,6 +6,9 @@ import { IdentitiesComponent } from './components/home/identities/identities.com
|
||||
import { IdentityComponent } from './components/home/identity/identity.component';
|
||||
import { InfoComponent } from './components/home/info/info.component';
|
||||
import { SettingsComponent } from './components/home/settings/settings.component';
|
||||
import { LogsComponent } from './components/home/logs/logs.component';
|
||||
import { BookmarksComponent } from './components/home/bookmarks/bookmarks.component';
|
||||
import { WalletComponent } from './components/home/wallet/wallet.component';
|
||||
import { NewIdentityComponent } from './components/new-identity/new-identity.component';
|
||||
import { EditIdentityComponent } from './components/edit-identity/edit-identity.component';
|
||||
import { HomeComponent as EditIdentityHomeComponent } from './components/edit-identity/home/home.component';
|
||||
@@ -17,6 +20,7 @@ import { VaultLoginComponent } from './components/vault-login/vault-login.compon
|
||||
import { VaultCreateComponent } from './components/vault-create/vault-create.component';
|
||||
import { VaultImportComponent } from './components/vault-import/vault-import.component';
|
||||
import { WhitelistedAppsComponent } from './components/whitelisted-apps/whitelisted-apps.component';
|
||||
import { ProfileEditComponent } from './components/profile-edit/profile-edit.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
@@ -65,6 +69,18 @@ export const routes: Routes = [
|
||||
path: 'settings',
|
||||
component: SettingsComponent,
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
component: LogsComponent,
|
||||
},
|
||||
{
|
||||
path: 'bookmarks',
|
||||
component: BookmarksComponent,
|
||||
},
|
||||
{
|
||||
path: 'wallet',
|
||||
component: WalletComponent,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -75,6 +91,10 @@ export const routes: Routes = [
|
||||
path: 'whitelisted-apps',
|
||||
component: WhitelistedAppsComponent,
|
||||
},
|
||||
{
|
||||
path: 'profile-edit',
|
||||
component: ProfileEditComponent,
|
||||
},
|
||||
{
|
||||
path: 'edit-identity/:id',
|
||||
component: EditIdentityComponent,
|
||||
|
||||
@@ -10,7 +10,12 @@
|
||||
<span class="text-muted" style="font-size: 12px">
|
||||
Nothing configured so far.
|
||||
</span>
|
||||
} @for(hostPermissions of hostsPermissions; track hostPermissions) {
|
||||
} @else {
|
||||
<button class="btn btn-danger btn-sm remove-all-btn" (click)="onClickRemoveAllPermissions()">
|
||||
Remove All Permissions
|
||||
</button>
|
||||
}
|
||||
@for(hostPermissions of hostsPermissions; track hostPermissions) {
|
||||
<div class="permissions-card">
|
||||
<span style="margin-bottom: 4px; font-weight: 500">
|
||||
{{ hostPermissions.host }}
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.remove-all-btn {
|
||||
margin-bottom: var(--size);
|
||||
}
|
||||
|
||||
.permissions-card {
|
||||
background: var(--background-light);
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -35,6 +35,14 @@ export class PermissionsComponent extends NavComponent implements OnInit {
|
||||
this.#buildHostsPermissions(this.identity?.id);
|
||||
}
|
||||
|
||||
async onClickRemoveAllPermissions() {
|
||||
const allPermissions = this.hostsPermissions.flatMap(hp => hp.permissions);
|
||||
for (const permission of allPermissions) {
|
||||
await this.#storage.deletePermission(permission.id);
|
||||
}
|
||||
this.#buildHostsPermissions(this.identity?.id);
|
||||
}
|
||||
|
||||
#initialize(identityId: string) {
|
||||
this.identity = this.#storage
|
||||
.getBrowserSessionHandler()
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</div>
|
||||
|
||||
<div class="info-banner">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<span class="emoji">💡</span>
|
||||
<span>These relays are fetched from your NIP-65 relay list (kind 10002). To update your relay list, use a Nostr client that supports NIP-65.</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
|
||||
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<span>Bookmarks</span>
|
||||
<button class="add-btn" title="Bookmark This Page" (click)="onBookmarkThisPage()">
|
||||
<span class="emoji">➕</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bookmarks-container">
|
||||
@if (isLoading) {
|
||||
<div class="loading-state">Loading...</div>
|
||||
} @else if (bookmarks.length === 0) {
|
||||
<div class="empty-state">
|
||||
<span class="sam-text-muted">
|
||||
No bookmarks yet. Click "Bookmark This Page" to add the current page.
|
||||
</span>
|
||||
</div>
|
||||
} @else {
|
||||
@for (bookmark of bookmarks; track bookmark.id) {
|
||||
<div class="bookmark-item" (click)="openBookmark(bookmark)">
|
||||
<div class="bookmark-info">
|
||||
<span class="bookmark-title">{{ bookmark.title }}</span>
|
||||
<span class="bookmark-url">{{ getDomain(bookmark.url) }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="remove-btn"
|
||||
title="Remove bookmark"
|
||||
(click)="onRemoveBookmark(bookmark); $event.stopPropagation()"
|
||||
>
|
||||
<span class="emoji">✕</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,112 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: var(--size);
|
||||
padding-bottom: var(--size);
|
||||
overflow: hidden;
|
||||
|
||||
> *:not(.sam-text-header) {
|
||||
margin-left: var(--size);
|
||||
margin-right: var(--size);
|
||||
}
|
||||
|
||||
.sam-text-header {
|
||||
margin-bottom: var(--size);
|
||||
flex-shrink: 0;
|
||||
|
||||
.add-btn {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background-light);
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bookmarks-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.empty-state,
|
||||
.loading-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.bookmark-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size-h);
|
||||
padding: var(--size-h) var(--size);
|
||||
margin-bottom: var(--size-hh);
|
||||
background: var(--background-light);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--background-light-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.bookmark-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.bookmark-title {
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bookmark-url {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
all: unset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
color: var(--muted-foreground);
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--destructive);
|
||||
color: var(--destructive-foreground);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Bookmark, LoggerService, SignerMetaData, StorageService } from '@common';
|
||||
import { FirefoxMetaHandler } from '../../../common/data/firefox-meta-handler';
|
||||
import browser from 'webextension-polyfill';
|
||||
|
||||
@Component({
|
||||
selector: 'app-bookmarks',
|
||||
templateUrl: './bookmarks.component.html',
|
||||
styleUrl: './bookmarks.component.scss',
|
||||
imports: [],
|
||||
})
|
||||
export class BookmarksComponent implements OnInit {
|
||||
readonly #logger = inject(LoggerService);
|
||||
readonly #metaHandler = new FirefoxMetaHandler();
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
|
||||
bookmarks: Bookmark[] = [];
|
||||
isLoading = true;
|
||||
|
||||
async ngOnInit() {
|
||||
await this.loadBookmarks();
|
||||
}
|
||||
|
||||
async loadBookmarks() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const metaData = await this.#metaHandler.loadFullData() as SignerMetaData;
|
||||
this.#metaHandler.setFullData(metaData);
|
||||
this.bookmarks = this.#metaHandler.getBookmarks();
|
||||
} catch (error) {
|
||||
console.error('Failed to load bookmarks:', error);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async onBookmarkThisPage() {
|
||||
try {
|
||||
// Get the current tab URL and title
|
||||
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tab?.url || !tab?.title) {
|
||||
console.error('Could not get current tab info');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already bookmarked
|
||||
if (this.bookmarks.some(b => b.url === tab.url)) {
|
||||
console.log('Page already bookmarked');
|
||||
return;
|
||||
}
|
||||
|
||||
const newBookmark: Bookmark = {
|
||||
id: crypto.randomUUID(),
|
||||
url: tab.url,
|
||||
title: tab.title,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
this.bookmarks = [newBookmark, ...this.bookmarks];
|
||||
await this.saveBookmarks();
|
||||
this.#logger.logBookmarkAdded(newBookmark.url, newBookmark.title);
|
||||
} catch (error) {
|
||||
console.error('Failed to bookmark page:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async onRemoveBookmark(bookmark: Bookmark) {
|
||||
this.bookmarks = this.bookmarks.filter(b => b.id !== bookmark.id);
|
||||
await this.saveBookmarks();
|
||||
this.#logger.logBookmarkRemoved(bookmark.url, bookmark.title);
|
||||
}
|
||||
|
||||
async saveBookmarks() {
|
||||
try {
|
||||
await this.#metaHandler.setBookmarks(this.bookmarks);
|
||||
} catch (error) {
|
||||
console.error('Failed to save bookmarks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
openBookmark(bookmark: Bookmark) {
|
||||
browser.tabs.create({ url: bookmark.url });
|
||||
}
|
||||
|
||||
getDomain(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.#storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
}
|
||||
@@ -3,34 +3,23 @@
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<a
|
||||
class="tab"
|
||||
routerLink="/home/identity"
|
||||
routerLinkActive="active"
|
||||
title="Your selected identity"
|
||||
>
|
||||
<i class="bi bi-person-circle"></i>
|
||||
<a class="tab" routerLink="/home/identity" routerLinkActive="active" title="You">
|
||||
<span class="emoji">👤</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
class="tab"
|
||||
routerLink="/home/identities"
|
||||
routerLinkActive="active"
|
||||
title="Identities"
|
||||
>
|
||||
<i class="bi bi-people-fill"></i>
|
||||
<a class="tab" routerLink="/home/identities" routerLinkActive="active" title="Identities">
|
||||
<span class="emoji">👥</span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
class="tab"
|
||||
routerLink="/home/settings"
|
||||
routerLinkActive="active"
|
||||
title="Settings"
|
||||
>
|
||||
<i class="bi bi-gear"></i>
|
||||
<a class="tab" routerLink="/home/wallet" routerLinkActive="active" title="Wallet">
|
||||
<span class="emoji">💰</span>
|
||||
</a>
|
||||
|
||||
<a class="tab" routerLink="/home/info" routerLinkActive="active" title="Info">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<a class="tab" routerLink="/home/bookmarks" routerLinkActive="active" title="Bookmarks">
|
||||
<span class="emoji">🔖</span>
|
||||
</a>
|
||||
|
||||
<a class="tab" routerLink="/home/settings" routerLinkActive="active" title="Settings">
|
||||
<span class="emoji">⚙️</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -4,17 +4,17 @@
|
||||
flex-direction: column;
|
||||
|
||||
.tab-content {
|
||||
height: calc(100% - 60px);
|
||||
height: calc(100% - 48px);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
height: 60px;
|
||||
min-height: 60px;
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
background: var(--background-light);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
a {
|
||||
a, button {
|
||||
all: unset;
|
||||
}
|
||||
|
||||
@@ -23,14 +23,17 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
|
||||
color: var(--muted-foreground);
|
||||
border-top: 3px solid transparent;
|
||||
border-top: 2px solid transparent;
|
||||
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
.emoji {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--background-light-hover);
|
||||
color: var(--foreground);
|
||||
@@ -38,7 +41,7 @@
|
||||
|
||||
&.active {
|
||||
color: var(--foreground);
|
||||
border-top: 3px solid var(--primary);
|
||||
border-top: 2px solid var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
|
||||
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
|
||||
<div class="custom-header" style="position: sticky; top: 0">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<span class="text">Identities</span>
|
||||
|
||||
<button class="button btn btn-primary btn-sm" (click)="onClickNewIdentity()">
|
||||
<div class="sam-flex-row gap-h">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
<span>New</span>
|
||||
</div>
|
||||
<button class="add-btn" title="New Identity" (click)="onClickNewIdentity()">
|
||||
<span class="emoji">➕</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
title="Manage whitelisted apps"
|
||||
(click)="onClickWhitelistedApps()"
|
||||
>
|
||||
<i class="bi bi-gear"></i>
|
||||
<span class="emoji">⚙️</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
/>
|
||||
<span class="name">{{ getDisplayName(identity) }}</span>
|
||||
<lib-icon-button
|
||||
icon="gear"
|
||||
icon="⚙️"
|
||||
title="Identity settings"
|
||||
(click)="onClickEditIdentity(identity.id, $event)"
|
||||
></lib-icon-button>
|
||||
|
||||
@@ -3,37 +3,58 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
|
||||
> *:not(.custom-header) {
|
||||
margin-left: var(--size);
|
||||
margin-right: var(--size);
|
||||
}
|
||||
|
||||
.custom-header {
|
||||
padding-top: var(--size);
|
||||
padding-bottom: var(--size);
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto;
|
||||
align-items: center;
|
||||
background: var(--background);
|
||||
position: relative;
|
||||
|
||||
.button {
|
||||
grid-column-start: 1;
|
||||
grid-column-end: 2;
|
||||
grid-row-start: 1;
|
||||
grid-row-end: 2;
|
||||
justify-self: end;
|
||||
.lock-btn,
|
||||
.add-btn {
|
||||
position: absolute;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background-light);
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.lock-btn {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
grid-column-start: 1;
|
||||
grid-column-end: 2;
|
||||
grid-row-start: 1;
|
||||
grid-row-end: 2;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1rem;
|
||||
justify-self: center;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Router } from '@angular/router';
|
||||
import {
|
||||
IconButtonComponent,
|
||||
Identity_DECRYPTED,
|
||||
LoggerService,
|
||||
NostrHelper,
|
||||
ProfileMetadata,
|
||||
ProfileMetadataService,
|
||||
@@ -20,6 +21,7 @@ export class IdentitiesComponent implements OnInit {
|
||||
readonly storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
readonly #profileMetadata = inject(ProfileMetadataService);
|
||||
readonly #logger = inject(LoggerService);
|
||||
|
||||
// Cache of pubkey -> profile for quick lookup
|
||||
#profileCache = new Map<string, ProfileMetadata | null>();
|
||||
@@ -73,4 +75,10 @@ export class IdentitiesComponent implements OnInit {
|
||||
onClickWhitelistedApps() {
|
||||
this.#router.navigateByUrl('/whitelisted-apps');
|
||||
}
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
|
||||
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<span>You</span>
|
||||
<button class="edit-btn" title="Edit profile" (click)="onClickEditProfile()">
|
||||
<span class="emoji">📝</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="identity-container">
|
||||
@@ -22,7 +29,6 @@
|
||||
</div>
|
||||
|
||||
<!-- Display name (primary, large) -->
|
||||
<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->
|
||||
<div class="name-badge-container" (click)="onClickShowDetails()">
|
||||
<span class="display-name">
|
||||
{{ displayName || selectedIdentity?.nick || 'Unknown' }}
|
||||
|
||||
@@ -3,6 +3,30 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.sam-text-header {
|
||||
.edit-btn {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background-light);
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.identity-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -123,6 +147,7 @@
|
||||
}
|
||||
|
||||
.nip05-row {
|
||||
@extend %text-badge;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@@ -134,7 +159,6 @@
|
||||
}
|
||||
|
||||
.nip05-badge {
|
||||
@extend %text-badge;
|
||||
font-size: 13px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, inject, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
Identity_DECRYPTED,
|
||||
LoggerService,
|
||||
NostrHelper,
|
||||
ProfileMetadata,
|
||||
ProfileMetadataService,
|
||||
@@ -9,8 +10,8 @@ import {
|
||||
StorageService,
|
||||
ToastComponent,
|
||||
VisualNip05Pipe,
|
||||
validateNip05,
|
||||
} from '@common';
|
||||
import NDK from '@nostr-dev-kit/ndk';
|
||||
|
||||
@Component({
|
||||
selector: 'app-identity',
|
||||
@@ -29,6 +30,7 @@ export class IdentityComponent implements OnInit {
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
readonly #profileMetadata = inject(ProfileMetadataService);
|
||||
readonly #logger = inject(LoggerService);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.#loadData();
|
||||
@@ -67,6 +69,19 @@ export class IdentityComponent implements OnInit {
|
||||
);
|
||||
}
|
||||
|
||||
onClickEditProfile() {
|
||||
if (!this.selectedIdentity) {
|
||||
return;
|
||||
}
|
||||
this.#router.navigateByUrl('/profile-edit');
|
||||
}
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.#storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
|
||||
async #loadData() {
|
||||
try {
|
||||
const selectedIdentityId =
|
||||
@@ -125,28 +140,21 @@ export class IdentityComponent implements OnInit {
|
||||
try {
|
||||
this.validating = true;
|
||||
|
||||
// Get relays for validation
|
||||
const relays =
|
||||
this.#storage
|
||||
.getBrowserSessionHandler()
|
||||
.browserSessionData?.relays.filter(
|
||||
(x) => x.identityId === this.selectedIdentity?.id
|
||||
) ?? [];
|
||||
// Direct NIP-05 validation - fetches .well-known/nostr.json directly
|
||||
const result = await validateNip05(nip05, pubkey);
|
||||
this.nip05isValidated = result.valid;
|
||||
|
||||
const relevantRelays = relays.filter((x) => x.write).map((x) => x.url);
|
||||
|
||||
if (relevantRelays.length > 0) {
|
||||
const ndk = new NDK({
|
||||
explicitRelayUrls: relevantRelays,
|
||||
});
|
||||
await ndk.connect();
|
||||
const user = ndk.getUser({ pubkey });
|
||||
this.nip05isValidated = (await user.validateNip05(nip05)) ?? undefined;
|
||||
if (result.valid) {
|
||||
this.#logger.logNip05ValidationSuccess(nip05, pubkey);
|
||||
} else {
|
||||
this.#logger.logNip05ValidationError(nip05, result.error ?? 'Unknown error');
|
||||
}
|
||||
|
||||
this.validating = false;
|
||||
} catch (error) {
|
||||
console.error('NIP-05 validation failed:', error);
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.#logger.logNip05ValidationError(nip05, errorMsg);
|
||||
this.nip05isValidated = false;
|
||||
this.validating = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<div class="sam-text-header">
|
||||
<button class="lock-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
<span> Plebeian Signer </span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
overflow-y: auto;
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
|
||||
> *:not(.sam-text-header) {
|
||||
margin-left: var(--size);
|
||||
margin-right: var(--size);
|
||||
}
|
||||
|
||||
.sam-text-header {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||