Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3750e99e61 | ||
|
|
2c1f3265b7 | ||
|
|
7ff8e257dd | ||
|
|
8b6ead1f81 | ||
|
|
38d9a9ef9f | ||
|
|
b55a3f01b6 | ||
|
b7bedf085a
|
|||
|
ff82e41012
|
|||
|
45b1fb58e9
|
|||
|
b535a7b967
|
|||
|
abd4a21f8f
|
|||
|
4b2d23e942
|
|||
|
ebe2b695cc
|
|||
|
ddb74c61b2
|
|||
|
5550d41293
|
@@ -5,15 +5,21 @@ Review all changes in the repository and create a release with proper commit mes
|
||||
## Argument: $ARGUMENTS
|
||||
|
||||
The argument should be one of:
|
||||
- `patch` - Bump the patch version (e.g., 0.0.4 -> 0.0.5)
|
||||
- `minor` - Bump the minor version and reset patch to 0 (e.g., 0.0.4 -> 0.1.0)
|
||||
- `major` - Bump the major version and reset minor/patch to 0 (e.g., 0.0.4 -> 1.0.0)
|
||||
- `patch` - Bump the patch version (e.g., v0.0.4 -> v0.0.5)
|
||||
- `minor` - Bump the minor version and reset patch to 0 (e.g., v0.0.4 -> v0.1.0)
|
||||
- `major` - Bump the major version and reset minor/patch to 0 (e.g., v0.0.4 -> v1.0.0)
|
||||
|
||||
If no argument provided, default to `patch`.
|
||||
|
||||
## Version Format
|
||||
|
||||
This project uses **standard semver with `v` prefix** (e.g., `v0.0.8`, `v1.2.3`).
|
||||
|
||||
## Steps to perform:
|
||||
|
||||
1. **Read the current version** from `package.json` (the `version` field)
|
||||
- Strip any existing `v` prefix if present (for backward compatibility with old `0.0.x` format)
|
||||
- The raw version should be in format: MAJOR.MINOR.PATCH
|
||||
|
||||
2. **Calculate the new version** based on the argument:
|
||||
- Parse the current version (format: MAJOR.MINOR.PATCH)
|
||||
@@ -21,10 +27,10 @@ If no argument provided, default to `patch`.
|
||||
- If `minor`: increment MINOR by 1, set PATCH to 0
|
||||
- If `major`: increment MAJOR by 1, set MINOR and PATCH to 0
|
||||
|
||||
3. **Update package.json** with the new version in all three places:
|
||||
- `version`
|
||||
- `custom.chrome.version`
|
||||
- `custom.firefox.version`
|
||||
3. **Update package.json** with the new version (with `v` prefix) in all three places:
|
||||
- `version` -> `vX.Y.Z`
|
||||
- `custom.chrome.version` -> `vX.Y.Z`
|
||||
- `custom.firefox.version` -> `vX.Y.Z`
|
||||
|
||||
4. **Review changes** using `git status` and `git diff --stat HEAD`
|
||||
|
||||
@@ -36,27 +42,38 @@ If no argument provided, default to `patch`.
|
||||
```
|
||||
If any step fails, fix issues before proceeding.
|
||||
|
||||
6. **Compose a commit message** following this format:
|
||||
- First line: 72 chars max, imperative mood summary (e.g., "Release v0.0.5")
|
||||
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** with the new version prefixed with 'v' (e.g., `v0.0.5`)
|
||||
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
|
||||
- All three version fields in package.json must be updated together
|
||||
- Always verify both Chrome and Firefox builds compile before committing
|
||||
- Version format is standard semver with `v` prefix: `vMAJOR.MINOR.PATCH`
|
||||
- Legacy versions without `v` prefix (e.g., `0.0.7`) are automatically upgraded to the new format
|
||||
|
||||
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:**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
version=$( cat package.json | jq '.custom.chrome.version' | tr -d '"')
|
||||
# Extract version and strip 'v' prefix if present (manifest requires bare semver)
|
||||
version=$( cat package.json | jq -r '.custom.chrome.version' | sed 's/^v//')
|
||||
|
||||
jq '.version = $newVersion' --arg newVersion $version ./projects/chrome/public/manifest.json > ./projects/chrome/public/tmp.manifest.json && mv ./projects/chrome/public/tmp.manifest.json ./projects/chrome/public/manifest.json
|
||||
|
||||
|
||||
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
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
version=$( cat package.json | jq '.custom.firefox.version' | tr -d '"')
|
||||
# Extract version and strip 'v' prefix if present (manifest requires bare semver)
|
||||
version=$( cat package.json | jq -r '.custom.firefox.version' | sed 's/^v//')
|
||||
|
||||
jq '.version = $newVersion' --arg newVersion $version ./projects/firefox/public/manifest.json > ./projects/firefox/public/tmp.manifest.json && mv ./projects/firefox/public/tmp.manifest.json ./projects/firefox/public/manifest.json
|
||||
|
||||
|
||||
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": "0.0.7",
|
||||
"version": "v1.0.8",
|
||||
"custom": {
|
||||
"chrome": {
|
||||
"version": "0.0.7"
|
||||
"version": "v1.0.8"
|
||||
},
|
||||
"firefox": {
|
||||
"version": "0.0.7"
|
||||
"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.7",
|
||||
"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": {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" fill="#ffffff" class="bi bi-person-fill" viewBox="0 0 16 16">
|
||||
<path d="M3 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6"/>
|
||||
</svg>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 4C11.606 4 11.2159 4.0776 10.8519 4.22836C10.488 4.37913 10.1573 4.6001 9.87868 4.87868C9.6001 5.15726 9.37913 5.48797 9.22836 5.85195C9.0776 6.21593 9 6.60603 9 7C9 7.39397 9.0776 7.78407 9.22836 8.14805C9.37913 8.51203 9.6001 8.84274 9.87868 9.12132C10.1573 9.3999 10.488 9.62087 10.8519 9.77164C11.2159 9.9224 11.606 10 12 10C12.7956 10 13.5587 9.68393 14.1213 9.12132C14.6839 8.55871 15 7.79565 15 7C15 6.20435 14.6839 5.44129 14.1213 4.87868C13.5587 4.31607 12.7956 4 12 4ZM7 7C7 5.67392 7.52678 4.40215 8.46447 3.46447C9.40215 2.52678 10.6739 2 12 2C13.3261 2 14.5979 2.52678 15.5355 3.46447C16.4732 4.40215 17 5.67392 17 7C17 8.32608 16.4732 9.59785 15.5355 10.5355C14.5979 11.4732 13.3261 12 12 12C10.6739 12 9.40215 11.4732 8.46447 10.5355C7.52678 9.59785 7 8.32608 7 7ZM3.5 19C3.5 17.6739 4.02678 16.4021 4.96447 15.4645C5.90215 14.5268 7.17392 14 8.5 14H15.5C16.1566 14 16.8068 14.1293 17.4134 14.3806C18.02 14.6319 18.5712 15.0002 19.0355 15.4645C19.4998 15.9288 19.8681 16.48 20.1194 17.0866C20.3707 17.6932 20.5 18.3434 20.5 19V21H18.5V19C18.5 18.2044 18.1839 17.4413 17.6213 16.8787C17.0587 16.3161 16.2956 16 15.5 16H8.5C7.70435 16 6.94129 16.3161 6.37868 16.8787C5.81607 17.4413 5.5 18.2044 5.5 19V21H3.5V19Z" fill="#FAFAFA"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 219 B After Width: | Height: | Size: 1.3 KiB |
|
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';
|
||||
@@ -16,6 +19,8 @@ import { KeysComponent as EditIdentityKeysComponent } from './components/edit-id
|
||||
import { PermissionsComponent as EditIdentityPermissionsComponent } from './components/edit-identity/permissions/permissions.component';
|
||||
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 = [
|
||||
{
|
||||
@@ -64,12 +69,32 @@ export const routes: Routes = [
|
||||
path: 'settings',
|
||||
component: SettingsComponent,
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
component: LogsComponent,
|
||||
},
|
||||
{
|
||||
path: 'bookmarks',
|
||||
component: BookmarksComponent,
|
||||
},
|
||||
{
|
||||
path: 'wallet',
|
||||
component: WalletComponent,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'new-identity',
|
||||
component: NewIdentityComponent,
|
||||
},
|
||||
{
|
||||
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,37 @@
|
||||
<!-- 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>
|
||||
|
||||
<div class="reckless-mode-row">
|
||||
<label class="reckless-label" (click)="onToggleRecklessMode()">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="isRecklessMode"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="onToggleRecklessMode()"
|
||||
/>
|
||||
<span
|
||||
class="reckless-text"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="bottom"
|
||||
title="Auto-approve all actions. If whitelist has entries, only those apps are auto-approved."
|
||||
>Reckless mode</span>
|
||||
</label>
|
||||
<button
|
||||
class="gear-btn"
|
||||
title="Manage whitelisted apps"
|
||||
(click)="onClickWhitelistedApps()"
|
||||
>
|
||||
<span class="emoji">⚙️</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -34,11 +58,11 @@
|
||||
class="avatar"
|
||||
[src]="getAvatarUrl(identity)"
|
||||
alt=""
|
||||
(error)="$any($event.target).src = 'assets/person-fill.svg'"
|
||||
(error)="$any($event.target).src = 'person-fill.svg'"
|
||||
/>
|
||||
<span class="name">{{ getDisplayName(identity) }}</span>
|
||||
<lib-icon-button
|
||||
icon="gear"
|
||||
icon="⚙️"
|
||||
title="Identity settings"
|
||||
(click)="onClickEditIdentity(identity.id, $event)"
|
||||
></lib-icon-button>
|
||||
|
||||
@@ -3,35 +3,109 @@
|
||||
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-size: 20px;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1rem;
|
||||
justify-self: center;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.reckless-mode-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--background-light);
|
||||
border-radius: 8px;
|
||||
|
||||
.reckless-label {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reckless-text {
|
||||
font-size: 14px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.gear-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted-foreground);
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s ease, background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--foreground);
|
||||
background: var(--background-light-hover);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Router } from '@angular/router';
|
||||
import {
|
||||
IconButtonComponent,
|
||||
Identity_DECRYPTED,
|
||||
LoggerService,
|
||||
NostrHelper,
|
||||
ProfileMetadata,
|
||||
ProfileMetadataService,
|
||||
@@ -20,10 +21,15 @@ 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>();
|
||||
|
||||
get isRecklessMode(): boolean {
|
||||
return this.storage.getSignerMetaHandler().signerMetaData?.recklessMode ?? false;
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.#profileMetadata.initialize();
|
||||
this.#loadProfiles();
|
||||
@@ -40,7 +46,7 @@ export class IdentitiesComponent implements OnInit {
|
||||
|
||||
getAvatarUrl(identity: Identity_DECRYPTED): string {
|
||||
const profile = this.#profileCache.get(identity.id);
|
||||
return profile?.picture || 'assets/person-fill.svg';
|
||||
return profile?.picture || 'person-fill.svg';
|
||||
}
|
||||
|
||||
getDisplayName(identity: Identity_DECRYPTED): string {
|
||||
@@ -60,4 +66,19 @@ export class IdentitiesComponent implements OnInit {
|
||||
async onClickSelectIdentity(identityId: string) {
|
||||
await this.storage.switchIdentity(identityId);
|
||||
}
|
||||
|
||||
async onToggleRecklessMode() {
|
||||
const newValue = !this.isRecklessMode;
|
||||
await this.storage.getSignerMetaHandler().setRecklessMode(newValue);
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<div class="custom-header">
|
||||
<button class="back-btn" (click)="onClickBack()">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</button>
|
||||
<span class="text">Whitelisted Apps</span>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<button
|
||||
class="btn btn-primary whitelist-btn"
|
||||
(click)="onClickWhitelistCurrentTab()"
|
||||
>
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
<span>Whitelist current tab</span>
|
||||
</button>
|
||||
|
||||
<div class="hosts-list">
|
||||
@if (whitelistedHosts.length === 0) {
|
||||
<div class="empty-state">
|
||||
<span class="sam-text-muted">No whitelisted apps yet</span>
|
||||
</div>
|
||||
@if (isRecklessMode) {
|
||||
<div class="warning-note">
|
||||
<span>⚠ All sites will be auto-approved without prompting</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@for (host of whitelistedHosts; track host) {
|
||||
<div class="host-item">
|
||||
<span class="host-name">{{ host }}</span>
|
||||
<button
|
||||
class="remove-btn"
|
||||
title="Remove from whitelist"
|
||||
(click)="onClickRemoveHost(host)"
|
||||
>
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<lib-toast #toast></lib-toast>
|
||||
<lib-confirm #confirm></lib-confirm>
|
||||
@@ -0,0 +1,134 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
|
||||
.custom-header {
|
||||
padding: var(--size);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto;
|
||||
align-items: center;
|
||||
background: var(--background);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
|
||||
.back-btn {
|
||||
grid-column-start: 1;
|
||||
grid-column-end: 2;
|
||||
grid-row-start: 1;
|
||||
grid-row-end: 2;
|
||||
justify-self: start;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--foreground);
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
z-index: 1;
|
||||
|
||||
&:hover {
|
||||
background: var(--background-light);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
grid-column-start: 1;
|
||||
grid-column-end: 2;
|
||||
grid-row-start: 1;
|
||||
grid-row-end: 2;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05rem;
|
||||
justify-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0 var(--size) var(--size) var(--size);
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.whitelist-btn {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: var(--size);
|
||||
}
|
||||
|
||||
.hosts-list {
|
||||
flex-grow: 1;
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.warning-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
background: rgba(255, 193, 7, 0.15);
|
||||
border: 1px solid rgba(255, 193, 7, 0.4);
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
color: #ffc107;
|
||||
}
|
||||
}
|
||||
|
||||
.host-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
background: var(--background-light);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.host-name {
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted-foreground);
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s ease, background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--destructive);
|
||||
background: var(--background-light-hover);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Component, inject, ViewChild } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
ConfirmComponent,
|
||||
NavComponent,
|
||||
StorageService,
|
||||
ToastComponent,
|
||||
} from '@common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-whitelisted-apps',
|
||||
templateUrl: './whitelisted-apps.component.html',
|
||||
styleUrl: './whitelisted-apps.component.scss',
|
||||
imports: [ToastComponent, ConfirmComponent],
|
||||
})
|
||||
export class WhitelistedAppsComponent extends NavComponent {
|
||||
@ViewChild('toast') toast!: ToastComponent;
|
||||
@ViewChild('confirm') confirm!: ConfirmComponent;
|
||||
|
||||
readonly storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
|
||||
get whitelistedHosts(): string[] {
|
||||
return this.storage.getSignerMetaHandler().signerMetaData?.whitelistedHosts ?? [];
|
||||
}
|
||||
|
||||
get isRecklessMode(): boolean {
|
||||
return this.storage.getSignerMetaHandler().signerMetaData?.recklessMode ?? false;
|
||||
}
|
||||
|
||||
async onClickWhitelistCurrentTab() {
|
||||
try {
|
||||
// Get current active tab
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (tabs.length === 0 || !tabs[0].url) {
|
||||
this.toast.show('No active tab found');
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(tabs[0].url);
|
||||
const host = url.host;
|
||||
|
||||
if (!host) {
|
||||
this.toast.show('Cannot get host from current tab');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already whitelisted
|
||||
if (this.whitelistedHosts.includes(host)) {
|
||||
this.toast.show(`${host} is already whitelisted`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.storage.getSignerMetaHandler().addWhitelistedHost(host);
|
||||
this.toast.show(`Added ${host} to whitelist`);
|
||||
} catch (error) {
|
||||
console.error('Error getting current tab:', error);
|
||||
this.toast.show('Error getting current tab');
|
||||
}
|
||||
}
|
||||
|
||||
onClickRemoveHost(host: string) {
|
||||
this.confirm.show(`Remove ${host} from whitelist?`, async () => {
|
||||
await this.storage.getSignerMetaHandler().removeWhitelistedHost(host);
|
||||
this.toast.show(`Removed ${host} from whitelist`);
|
||||
});
|
||||
}
|
||||
|
||||
onClickBack() {
|
||||
this.#router.navigateByUrl('/home/identities');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -48,6 +49,42 @@ export const getBrowserSessionData = async function (): Promise<
|
||||
return browserSessionData as BrowserSessionData;
|
||||
};
|
||||
|
||||
export const getSignerMetaData = async function (): Promise<SignerMetaData> {
|
||||
const signerMetaHandler = new ChromeMetaHandler();
|
||||
return (await signerMetaHandler.loadFullData()) as SignerMetaData;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if reckless mode should auto-approve the request.
|
||||
* Returns true if should auto-approve, false if should use normal permission flow.
|
||||
*
|
||||
* Logic:
|
||||
* - If reckless mode is OFF → return false (use normal flow)
|
||||
* - If reckless mode is ON and whitelist is empty → return true (approve all)
|
||||
* - If reckless mode is ON and whitelist has entries → return true only if host is in whitelist
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
const whitelistedHosts = signerMetaData.whitelistedHosts ?? [];
|
||||
|
||||
if (whitelistedHosts.length === 0) {
|
||||
// Reckless mode ON, no whitelist → approve all
|
||||
return true;
|
||||
}
|
||||
|
||||
// Reckless mode ON, whitelist has entries → only approve if host is whitelisted
|
||||
return whitelistedHosts.includes(host);
|
||||
};
|
||||
|
||||
export const getBrowserSyncData = async function (): Promise<
|
||||
BrowserSyncData | undefined
|
||||
> {
|
||||
@@ -189,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([
|
||||
@@ -287,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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -311,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,
|
||||
@@ -12,6 +16,7 @@ import {
|
||||
nip44Encrypt,
|
||||
PromptResponse,
|
||||
PromptResponseMessage,
|
||||
shouldRecklessModeApprove,
|
||||
signEvent,
|
||||
storePermission,
|
||||
} from './background-common';
|
||||
@@ -63,100 +68,146 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
||||
}
|
||||
|
||||
const req = request as BackgroundRequestMessage;
|
||||
const permissionState = checkPermissions(
|
||||
browserSessionData,
|
||||
currentIdentity,
|
||||
req.host,
|
||||
req.method,
|
||||
req.params
|
||||
);
|
||||
|
||||
if (permissionState === false) {
|
||||
throw new Error('Permission denied');
|
||||
}
|
||||
// Check reckless mode first
|
||||
const recklessApprove = await shouldRecklessModeApprove(req.host);
|
||||
debug(`recklessApprove result: ${recklessApprove}`);
|
||||
if (recklessApprove) {
|
||||
debug('Request auto-approved via reckless mode.');
|
||||
} else {
|
||||
// Normal permission flow
|
||||
const permissionState = checkPermissions(
|
||||
browserSessionData,
|
||||
currentIdentity,
|
||||
req.host,
|
||||
req.method,
|
||||
req.params
|
||||
);
|
||||
debug(`permissionState result: ${permissionState}`);
|
||||
|
||||
if (permissionState === undefined) {
|
||||
// Ask user for permission.
|
||||
const width = 375;
|
||||
const height = 600;
|
||||
const { top, left } = await getPosition(width, height);
|
||||
|
||||
const base64Event = Buffer.from(
|
||||
JSON.stringify(req.params ?? {}, undefined, 2)
|
||||
).toString('base64');
|
||||
|
||||
const response = await new Promise<PromptResponse>((resolve, reject) => {
|
||||
const id = crypto.randomUUID();
|
||||
openPrompts.set(id, { resolve, reject });
|
||||
browser.windows.create({
|
||||
type: 'popup',
|
||||
url: `prompt.html?method=${req.method}&host=${req.host}&id=${id}&nick=${currentIdentity.nick}&event=${base64Event}`,
|
||||
height,
|
||||
width,
|
||||
top,
|
||||
left,
|
||||
});
|
||||
});
|
||||
debug(response);
|
||||
if (response === 'approve' || response === 'reject') {
|
||||
await storePermission(
|
||||
browserSessionData,
|
||||
currentIdentity,
|
||||
req.host,
|
||||
req.method,
|
||||
response === 'approve' ? 'allow' : 'deny',
|
||||
req.params?.kind
|
||||
);
|
||||
}
|
||||
|
||||
if (['reject', 'reject-once'].includes(response)) {
|
||||
if (permissionState === false) {
|
||||
throw new Error('Permission denied');
|
||||
}
|
||||
} else {
|
||||
debug('Request allowed (via saved permission).');
|
||||
|
||||
if (permissionState === undefined) {
|
||||
// Ask user for permission.
|
||||
const width = 375;
|
||||
const height = 600;
|
||||
const { top, left } = await getPosition(width, height);
|
||||
|
||||
const base64Event = Buffer.from(
|
||||
JSON.stringify(req.params ?? {}, undefined, 2)
|
||||
).toString('base64');
|
||||
|
||||
const response = await new Promise<PromptResponse>((resolve, reject) => {
|
||||
const id = crypto.randomUUID();
|
||||
openPrompts.set(id, { resolve, reject });
|
||||
browser.windows.create({
|
||||
type: 'popup',
|
||||
url: `prompt.html?method=${req.method}&host=${req.host}&id=${id}&nick=${currentIdentity.nick}&event=${base64Event}`,
|
||||
height,
|
||||
width,
|
||||
top,
|
||||
left,
|
||||
});
|
||||
});
|
||||
debug(response);
|
||||
if (response === 'approve' || response === 'reject') {
|
||||
const policy = response === 'approve' ? 'allow' : 'deny';
|
||||
await storePermission(
|
||||
browserSessionData,
|
||||
currentIdentity,
|
||||
req.host,
|
||||
req.method,
|
||||
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 {
|
||||
debug('Request allowed (via saved permission).');
|
||||
}
|
||||
}
|
||||
|
||||
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'];
|
||||
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),
|
||||
@@ -40,4 +40,75 @@ export abstract class SignerMetaHandler {
|
||||
}
|
||||
|
||||
abstract clearData(keep: string[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Sets the reckless mode and immediately saves it.
|
||||
*/
|
||||
async setRecklessMode(enabled: boolean): Promise<void> {
|
||||
if (!this.#signerMetaData) {
|
||||
this.#signerMetaData = {
|
||||
recklessMode: enabled,
|
||||
};
|
||||
} else {
|
||||
this.#signerMetaData.recklessMode = enabled;
|
||||
}
|
||||
|
||||
await this.saveFullData(this.#signerMetaData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a host to the whitelist and immediately saves it.
|
||||
*/
|
||||
async addWhitelistedHost(host: string): Promise<void> {
|
||||
if (!this.#signerMetaData) {
|
||||
this.#signerMetaData = {
|
||||
whitelistedHosts: [host],
|
||||
};
|
||||
} else {
|
||||
const hosts = this.#signerMetaData.whitelistedHosts ?? [];
|
||||
if (!hosts.includes(host)) {
|
||||
hosts.push(host);
|
||||
this.#signerMetaData.whitelistedHosts = hosts;
|
||||
}
|
||||
}
|
||||
|
||||
await this.saveFullData(this.#signerMetaData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a host from the whitelist and immediately saves it.
|
||||
*/
|
||||
async removeWhitelistedHost(host: string): Promise<void> {
|
||||
if (!this.#signerMetaData?.whitelistedHosts) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#signerMetaData.whitelistedHosts = this.#signerMetaData.whitelistedHosts.filter(
|
||||
(h) => h !== host
|
||||
);
|
||||
|
||||
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,10 +94,29 @@ 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))
|
||||
|
||||
vaultSnapshots?: SignerMetaData_VaultSnapshot[];
|
||||
|
||||
// Reckless mode: auto-approve all actions without prompting
|
||||
recklessMode?: boolean;
|
||||
|
||||
// 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.7",
|
||||
"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": {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" fill="#ffffff" class="bi bi-person-fill" viewBox="0 0 16 16">
|
||||
<path d="M3 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6"/>
|
||||
</svg>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 4C11.606 4 11.2159 4.0776 10.8519 4.22836C10.488 4.37913 10.1573 4.6001 9.87868 4.87868C9.6001 5.15726 9.37913 5.48797 9.22836 5.85195C9.0776 6.21593 9 6.60603 9 7C9 7.39397 9.0776 7.78407 9.22836 8.14805C9.37913 8.51203 9.6001 8.84274 9.87868 9.12132C10.1573 9.3999 10.488 9.62087 10.8519 9.77164C11.2159 9.9224 11.606 10 12 10C12.7956 10 13.5587 9.68393 14.1213 9.12132C14.6839 8.55871 15 7.79565 15 7C15 6.20435 14.6839 5.44129 14.1213 4.87868C13.5587 4.31607 12.7956 4 12 4ZM7 7C7 5.67392 7.52678 4.40215 8.46447 3.46447C9.40215 2.52678 10.6739 2 12 2C13.3261 2 14.5979 2.52678 15.5355 3.46447C16.4732 4.40215 17 5.67392 17 7C17 8.32608 16.4732 9.59785 15.5355 10.5355C14.5979 11.4732 13.3261 12 12 12C10.6739 12 9.40215 11.4732 8.46447 10.5355C7.52678 9.59785 7 8.32608 7 7ZM3.5 19C3.5 17.6739 4.02678 16.4021 4.96447 15.4645C5.90215 14.5268 7.17392 14 8.5 14H15.5C16.1566 14 16.8068 14.1293 17.4134 14.3806C18.02 14.6319 18.5712 15.0002 19.0355 15.4645C19.4998 15.9288 19.8681 16.48 20.1194 17.0866C20.3707 17.6932 20.5 18.3434 20.5 19V21H18.5V19C18.5 18.2044 18.1839 17.4413 17.6213 16.8787C17.0587 16.3161 16.2956 16 15.5 16H8.5C7.70435 16 6.94129 16.3161 6.37868 16.8787C5.81607 17.4413 5.5 18.2044 5.5 19V21H3.5V19Z" fill="#FAFAFA"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 219 B After Width: | Height: | Size: 1.3 KiB |
|
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';
|
||||
@@ -16,6 +19,8 @@ import { WelcomeComponent } from './components/welcome/welcome.component';
|
||||
import { VaultLoginComponent } from './components/vault-login/vault-login.component';
|
||||
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 = [
|
||||
{
|
||||
@@ -64,12 +69,32 @@ export const routes: Routes = [
|
||||
path: 'settings',
|
||||
component: SettingsComponent,
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
component: LogsComponent,
|
||||
},
|
||||
{
|
||||
path: 'bookmarks',
|
||||
component: BookmarksComponent,
|
||||
},
|
||||
{
|
||||
path: 'wallet',
|
||||
component: WalletComponent,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'new-identity',
|
||||
component: NewIdentityComponent,
|
||||
},
|
||||
{
|
||||
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,37 @@
|
||||
<!-- 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>
|
||||
|
||||
<div class="reckless-mode-row">
|
||||
<label class="reckless-label" (click)="onToggleRecklessMode()">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="isRecklessMode"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="onToggleRecklessMode()"
|
||||
/>
|
||||
<span
|
||||
class="reckless-text"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="bottom"
|
||||
title="Auto-approve all actions. If whitelist has entries, only those apps are auto-approved."
|
||||
>Reckless mode</span>
|
||||
</label>
|
||||
<button
|
||||
class="gear-btn"
|
||||
title="Manage whitelisted apps"
|
||||
(click)="onClickWhitelistedApps()"
|
||||
>
|
||||
<span class="emoji">⚙️</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -34,11 +58,11 @@
|
||||
class="avatar"
|
||||
[src]="getAvatarUrl(identity)"
|
||||
alt=""
|
||||
(error)="$any($event.target).src = 'assets/person-fill.svg'"
|
||||
(error)="$any($event.target).src = 'person-fill.svg'"
|
||||
/>
|
||||
<span class="name">{{ getDisplayName(identity) }}</span>
|
||||
<lib-icon-button
|
||||
icon="gear"
|
||||
icon="⚙️"
|
||||
title="Identity settings"
|
||||
(click)="onClickEditIdentity(identity.id, $event)"
|
||||
></lib-icon-button>
|
||||
|
||||