Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
482356a9e4 | ||
|
|
a90eafbf18 | ||
|
|
58e9053867 | ||
|
|
5183a4fc0a | ||
|
|
a2d0a9bd32 | ||
|
|
5cf0fed4ed | ||
|
|
4a2bc4fe72 | ||
| a2e47d8612 | |||
| 2074c409f0 | |||
|
|
c11887dfa8 | ||
|
|
d98a0ef76e | ||
|
|
87d76bb4a8 | ||
|
|
57434681f9 | ||
|
|
586e2ab23f | ||
|
|
5ca6eb177c | ||
|
|
ebc96e7201 | ||
|
|
1f8d478cd7 | ||
|
|
3750e99e61 | ||
|
|
2c1f3265b7 | ||
|
|
7ff8e257dd | ||
|
|
8b6ead1f81 | ||
|
|
38d9a9ef9f | ||
|
|
b55a3f01b6 | ||
|
b7bedf085a
|
|||
|
ff82e41012
|
|||
|
45b1fb58e9
|
|||
|
b535a7b967
|
|||
|
abd4a21f8f
|
|||
|
4b2d23e942
|
|||
|
ebe2b695cc
|
|||
|
ddb74c61b2
|
@@ -42,25 +42,34 @@ This project uses **standard semver with `v` prefix** (e.g., `v0.0.8`, `v1.2.3`)
|
||||
```
|
||||
If any step fails, fix issues before proceeding.
|
||||
|
||||
6. **Compose a commit message** following this format:
|
||||
6. **Create release zip files** in the `releases/` folder:
|
||||
```
|
||||
mkdir -p releases
|
||||
rm -f releases/plebeian-signer-chrome-v*.zip releases/plebeian-signer-firefox-v*.zip
|
||||
cd dist/chrome && zip -r ../../releases/plebeian-signer-chrome-vX.Y.Z.zip . && cd ../..
|
||||
cd dist/firefox && zip -r ../../releases/plebeian-signer-firefox-vX.Y.Z.zip . && cd ../..
|
||||
```
|
||||
Replace `vX.Y.Z` with the actual version number. Old zip files are deleted to keep only the latest release.
|
||||
|
||||
7. **Compose a commit message** following this format:
|
||||
- First line: 72 chars max, imperative mood summary (e.g., "Release v0.0.8")
|
||||
- Blank line
|
||||
- Bullet points describing each significant change
|
||||
- "Files modified:" section listing affected files
|
||||
- Footer with Claude Code attribution
|
||||
|
||||
7. **Stage all changes** with `git add -A`
|
||||
8. **Stage all changes** with `git add -A`
|
||||
|
||||
8. **Create the commit** with the composed message
|
||||
9. **Create the commit** with the composed message
|
||||
|
||||
9. **Create a git tag** matching the version (e.g., `v0.0.8`)
|
||||
10. **Create a git tag** matching the version (e.g., `v0.0.8`)
|
||||
|
||||
10. **Push to origin** with tags:
|
||||
11. **Push to origin** with tags:
|
||||
```
|
||||
git push origin main --tags
|
||||
```
|
||||
|
||||
11. **Report completion** with the new version and commit hash
|
||||
12. **Report completion** with the new version and commit hash
|
||||
|
||||
## Important:
|
||||
- This is a browser extension with separate Chrome and Firefox builds
|
||||
|
||||
25
CLAUDE.md
@@ -27,7 +27,7 @@ npm run build:chrome && npm run build:firefox
|
||||
|
||||
### Monorepo Structure
|
||||
|
||||
This is an Angular CLI monorepo with three projects:
|
||||
This is an Angular 19 CLI monorepo with three projects:
|
||||
|
||||
- **projects/chrome**: Chrome extension (MV3)
|
||||
- **projects/firefox**: Firefox extension
|
||||
@@ -49,10 +49,20 @@ Message flow: Web App → `window.nostr` → Content Script → Background → C
|
||||
|
||||
- **BrowserSyncHandler**: Encrypted vault data synced across browser instances (or local-only based on user preference)
|
||||
- **BrowserSessionHandler**: Session-scoped decrypted data (unlocked vault state)
|
||||
- **SignerMetaHandler**: Extension metadata (sync flow preference)
|
||||
- **SignerMetaHandler**: Extension metadata (sync flow preference, reckless mode, whitelisted hosts)
|
||||
|
||||
Each browser (Chrome/Firefox) has its own handler implementations in `projects/{browser}/src/app/common/data/`.
|
||||
|
||||
### Vault Encryption (v2)
|
||||
|
||||
The vault uses Argon2id + AES-256-GCM for password-based encryption:
|
||||
- **Key derivation**: Argon2id with 256MB memory, 4 threads, 8 iterations (~3 second derivation)
|
||||
- **Encryption**: AES-256-GCM with random 12-byte IV per encryption
|
||||
- **Salt**: Random 32-byte salt per vault (stored in `BrowserSyncData.salt`)
|
||||
- The derived key is cached in session storage (`BrowserSessionData.vaultKey`) to avoid re-derivation on each operation
|
||||
|
||||
Note: Argon2id runs on main thread via WebAssembly (hash-wasm) because Web Workers cannot load external scripts in browser extensions due to CSP restrictions. A deriving modal provides user feedback during the ~3 second operation.
|
||||
|
||||
### Custom Webpack Build
|
||||
|
||||
Both extensions use `@angular-builders/custom-webpack` to bundle additional entry points beyond the main Angular app:
|
||||
@@ -66,9 +76,18 @@ Both extensions use `@angular-builders/custom-webpack` to bundle additional entr
|
||||
|
||||
The `@common` import alias resolves to `projects/common/src/public-api.ts`. Key exports:
|
||||
- `StorageService`: Central data management with encryption/decryption
|
||||
- `CryptoHelper`, `NostrHelper`: Cryptographic utilities
|
||||
- `CryptoHelper`, `NostrHelper`: Cryptographic utilities (nostr-tools based)
|
||||
- `Argon2Crypto`: Vault encryption with Argon2id key derivation
|
||||
- Shared Angular components and pipes
|
||||
|
||||
### Permission System
|
||||
|
||||
Permissions are stored per identity+host+method combination. The background script checks permissions before executing NIP-07 methods:
|
||||
- `allow`/`deny` policies can be stored for each method
|
||||
- Kind-specific permissions supported for `signEvent`
|
||||
- **Reckless mode**: Auto-approves all actions without prompting (global setting)
|
||||
- **Whitelisted hosts**: Auto-approves all actions from specific hosts
|
||||
|
||||
## Testing Extensions Locally
|
||||
|
||||
**Chrome:**
|
||||
|
||||
690
DDD_ANALYSIS.md
Normal file
@@ -0,0 +1,690 @@
|
||||
# Domain-Driven Design Analysis: Plebeian Signer
|
||||
|
||||
This document analyzes the Plebeian Signer codebase through the lens of Domain-Driven Design (DDD) principles, identifying bounded contexts, current patterns, anti-patterns, and providing actionable recommendations for improvement.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Plebeian Signer is a browser extension for Nostr identity management implementing NIP-07. The codebase has **good structural foundations** (monorepo with shared library, handler abstraction pattern) but suffers from several DDD anti-patterns:
|
||||
|
||||
- **God Service**: `StorageService` handles too many responsibilities
|
||||
- **Anemic Domain Models**: Types are data containers without behavior
|
||||
- **Mixed Concerns**: Encryption logic interleaved with domain operations
|
||||
- **Weak Ubiquitous Language**: Generic naming (`BrowserSyncData`) obscures domain concepts
|
||||
|
||||
**Priority Recommendations:**
|
||||
1. Extract domain aggregates with behavior (Identity, Vault, Wallet)
|
||||
2. Separate encryption into an infrastructure layer
|
||||
3. Introduce repository pattern for each aggregate
|
||||
4. Rename types to reflect ubiquitous language
|
||||
|
||||
---
|
||||
|
||||
## Domain Overview
|
||||
|
||||
### Core Domain Problem
|
||||
|
||||
> Enable users to manage multiple Nostr identities securely, sign events without exposing private keys to web applications, and interact with Lightning/Cashu wallets.
|
||||
|
||||
### Subdomain Classification
|
||||
|
||||
| Subdomain | Type | Rationale |
|
||||
|-----------|------|-----------|
|
||||
| **Identity & Signing** | Core | The differentiator - secure key management and NIP-07 implementation |
|
||||
| **Permission Management** | Core | Critical security layer - controls what apps can do |
|
||||
| **Vault Encryption** | Supporting | Necessary security but standard cryptographic patterns |
|
||||
| **Wallet Integration** | Supporting | Extends functionality but not the core value proposition |
|
||||
| **Profile Caching** | Generic | Standard caching pattern, could use any solution |
|
||||
| **Relay Management** | Supporting | Per-identity configuration, fairly standard |
|
||||
|
||||
---
|
||||
|
||||
## Bounded Contexts
|
||||
|
||||
### Identified Contexts
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ CONTEXT MAP │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────┐ Shared Kernel ┌──────────────────┐ │
|
||||
│ │ Vault Context │◄─────────(crypto)──────────►│ Identity Context │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ - VaultState │ │ - Identity │ │
|
||||
│ │ - Encryption │ │ - KeyPair │ │
|
||||
│ │ - Migration │ │ - Signing │ │
|
||||
│ └────────┬─────────┘ └────────┬─────────┘ │
|
||||
│ │ │ │
|
||||
│ │ Customer/Supplier │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ Permission Ctx │ │ Wallet Context │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ - Policy │ │ - NWC │ │
|
||||
│ │ - Host Rules │ │ - Cashu │ │
|
||||
│ │ - Method Auth │ │ - Lightning │ │
|
||||
│ └──────────────────┘ └──────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ Relay Context │◄──── Conformist ────────────►│ Profile Context │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ - Per-identity │ │ - Kind 0 cache │ │
|
||||
│ │ - Read/Write │ │ - Metadata │ │
|
||||
│ └──────────────────┘ └──────────────────┘ │
|
||||
│ │
|
||||
│ Legend: ◄──► Bidirectional, ──► Supplier direction │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Context Definitions
|
||||
|
||||
#### 1. Vault Context
|
||||
**Responsibility:** Secure storage lifecycle - creation, locking, unlocking, encryption, migration.
|
||||
|
||||
**Current Location:** `projects/common/src/lib/services/storage/related/vault.ts`
|
||||
|
||||
**Key Concepts:**
|
||||
- VaultState (locked/unlocked)
|
||||
- EncryptionKey (Argon2id-derived)
|
||||
- VaultVersion (migration support)
|
||||
- Salt, IV (cryptographic parameters)
|
||||
|
||||
**Language:**
|
||||
| Term | Definition |
|
||||
|------|------------|
|
||||
| Vault | The encrypted container holding all sensitive data |
|
||||
| Unlock | Derive key from password and decrypt vault contents |
|
||||
| Lock | Clear session data, requiring password to access again |
|
||||
| Migration | Upgrade vault encryption scheme (v1→v2) |
|
||||
|
||||
#### 2. Identity Context
|
||||
**Responsibility:** Nostr identity lifecycle and cryptographic operations.
|
||||
|
||||
**Current Location:** `projects/common/src/lib/services/storage/related/identity.ts`
|
||||
|
||||
**Key Concepts:**
|
||||
- Identity (aggregates pubkey, privkey, nick)
|
||||
- KeyPair (hex or nsec/npub representations)
|
||||
- SelectedIdentity (current active identity)
|
||||
- EventSigning (NIP-07 signEvent)
|
||||
|
||||
**Language:**
|
||||
| Term | Definition |
|
||||
|------|------------|
|
||||
| Identity | A Nostr keypair with a user-defined nickname |
|
||||
| Selected Identity | The currently active identity for signing |
|
||||
| Sign | Create schnorr signature for a Nostr event |
|
||||
| Switch | Change the active identity |
|
||||
|
||||
#### 3. Permission Context
|
||||
**Responsibility:** Authorization decisions for NIP-07 method calls.
|
||||
|
||||
**Current Location:** `projects/common/src/lib/services/storage/related/permission.ts`
|
||||
|
||||
**Key Concepts:**
|
||||
- PermissionPolicy (allow/deny)
|
||||
- MethodPermission (per NIP-07 method)
|
||||
- KindPermission (signEvent kind filtering)
|
||||
- HostWhitelist (trusted domains)
|
||||
- RecklessMode (auto-approve all)
|
||||
|
||||
**Language:**
|
||||
| Term | Definition |
|
||||
|------|------------|
|
||||
| Permission | A stored allow/deny decision for identity+host+method |
|
||||
| Reckless Mode | Global setting to auto-approve all requests |
|
||||
| Whitelist | Hosts that auto-approve without prompting |
|
||||
| Prompt | UI asking user to authorize a request |
|
||||
|
||||
#### 4. Wallet Context
|
||||
**Responsibility:** Lightning and Cashu wallet operations.
|
||||
|
||||
**Current Location:**
|
||||
- `projects/common/src/lib/services/nwc/`
|
||||
- `projects/common/src/lib/services/cashu/`
|
||||
- `projects/common/src/lib/services/storage/related/nwc.ts`
|
||||
- `projects/common/src/lib/services/storage/related/cashu.ts`
|
||||
|
||||
**Key Concepts:**
|
||||
- NwcConnection (NIP-47 wallet connect)
|
||||
- CashuMint (ecash mint connection)
|
||||
- CashuProof (unspent tokens)
|
||||
- LightningInvoice, Keysend
|
||||
|
||||
#### 5. Relay Context
|
||||
**Responsibility:** Per-identity relay configuration.
|
||||
|
||||
**Current Location:** `projects/common/src/lib/services/storage/related/relay.ts`
|
||||
|
||||
**Key Concepts:**
|
||||
- RelayConfiguration (URL + read/write permissions)
|
||||
- IdentityRelays (relays scoped to an identity)
|
||||
|
||||
#### 6. Profile Context
|
||||
**Responsibility:** Caching Nostr profile metadata (kind 0 events).
|
||||
|
||||
**Current Location:** `projects/common/src/lib/services/profile-metadata/`
|
||||
|
||||
**Key Concepts:**
|
||||
- ProfileMetadata (name, picture, nip05, etc.)
|
||||
- MetadataCache (fetchedAt timestamp)
|
||||
|
||||
---
|
||||
|
||||
## Current Architecture Analysis
|
||||
|
||||
### What's Working Well
|
||||
|
||||
1. **Monorepo Structure**
|
||||
- Clean separation: `projects/common`, `projects/chrome`, `projects/firefox`
|
||||
- Shared library via `@common` alias
|
||||
- Browser-specific implementations isolated
|
||||
|
||||
2. **Handler Abstraction (Adapter Pattern)**
|
||||
```
|
||||
StorageService
|
||||
├→ BrowserSessionHandler (abstract → ChromeSessionHandler, FirefoxSessionHandler)
|
||||
├→ BrowserSyncHandler (abstract → ChromeSyncYesHandler, ChromeSyncNoHandler, ...)
|
||||
└→ SignerMetaHandler (abstract → ChromeMetaHandler, FirefoxMetaHandler)
|
||||
```
|
||||
This enables pluggable browser implementations - good DDD practice.
|
||||
|
||||
3. **Encrypted/Decrypted Type Pairs**
|
||||
- `Identity_DECRYPTED` / `Identity_ENCRYPTED`
|
||||
- Clear distinction between storage states
|
||||
|
||||
4. **Vault Versioning**
|
||||
- Migration path from v1 (PBKDF2) to v2 (Argon2id)
|
||||
- Automatic upgrade on unlock
|
||||
|
||||
5. **Cascade Deletes**
|
||||
- Deleting an identity removes associated permissions and relays
|
||||
- Maintains referential integrity
|
||||
|
||||
### Anti-Patterns Identified
|
||||
|
||||
#### 1. God Service (`StorageService`)
|
||||
|
||||
**Location:** `projects/common/src/lib/services/storage/storage.service.ts`
|
||||
|
||||
**Problem:** Single service handles:
|
||||
- Vault lifecycle (create, unlock, delete, migrate)
|
||||
- Identity CRUD (add, delete, switch)
|
||||
- Permission management
|
||||
- Relay configuration
|
||||
- NWC wallet connections
|
||||
- Cashu mint management
|
||||
- Encryption/decryption orchestration
|
||||
|
||||
**Symptoms:**
|
||||
- 500+ lines when including bound methods
|
||||
- Methods dynamically attached via functional composition
|
||||
- Implicit dependencies between operations
|
||||
- Difficult to test in isolation
|
||||
|
||||
**DDD Violation:** Violates single responsibility; should be split into aggregate-specific repositories.
|
||||
|
||||
#### 2. Anemic Domain Models
|
||||
|
||||
**Location:** `projects/common/src/lib/services/storage/types.ts`
|
||||
|
||||
**Problem:** All domain types are pure data containers:
|
||||
|
||||
```typescript
|
||||
// Current: Anemic model
|
||||
interface Identity_DECRYPTED {
|
||||
id: string;
|
||||
nick: string;
|
||||
privkey: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// All behavior lives in external functions:
|
||||
// - addIdentity() in identity.ts
|
||||
// - switchIdentity() in identity.ts
|
||||
// - encryptIdentity() in identity.ts
|
||||
```
|
||||
|
||||
**Should Be:**
|
||||
```typescript
|
||||
// Rich domain model
|
||||
class Identity {
|
||||
private constructor(
|
||||
private readonly _id: IdentityId,
|
||||
private _nick: Nickname,
|
||||
private readonly _keyPair: NostrKeyPair,
|
||||
private readonly _createdAt: Date
|
||||
) {}
|
||||
|
||||
static create(nick: string, privateKey?: string): Identity { /* ... */ }
|
||||
|
||||
get publicKey(): string { return this._keyPair.publicKey; }
|
||||
|
||||
sign(event: UnsignedEvent): SignedEvent {
|
||||
return this._keyPair.sign(event);
|
||||
}
|
||||
|
||||
rename(newNick: string): void {
|
||||
this._nick = Nickname.create(newNick);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Mixed Encryption Concerns
|
||||
|
||||
**Problem:** Domain operations and encryption logic are interleaved:
|
||||
|
||||
```typescript
|
||||
// In identity.ts
|
||||
export async function addIdentity(this: StorageService, data: {...}) {
|
||||
// Domain logic
|
||||
const identity_decrypted: Identity_DECRYPTED = {
|
||||
id: uuid(),
|
||||
nick: data.nick,
|
||||
privkey: data.privkeyString,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Encryption concern mixed in
|
||||
const identity_encrypted = await encryptIdentity.call(this, identity_decrypted);
|
||||
|
||||
// Storage concern
|
||||
await this.#browserSyncHandler.addIdentity(identity_encrypted);
|
||||
this.#browserSessionHandler.addIdentity(identity_decrypted);
|
||||
}
|
||||
```
|
||||
|
||||
**Should Be:** Encryption as infrastructure layer, repositories handle persistence:
|
||||
|
||||
```typescript
|
||||
class IdentityRepository {
|
||||
async save(identity: Identity): Promise<void> {
|
||||
const encrypted = this.encryptionService.encrypt(identity.toSnapshot());
|
||||
await this.syncHandler.save(encrypted);
|
||||
this.sessionHandler.cache(identity);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Weak Ubiquitous Language
|
||||
|
||||
**Problem:** Type names reflect technical storage, not domain concepts:
|
||||
|
||||
| Current Name | Domain Concept |
|
||||
|--------------|----------------|
|
||||
| `BrowserSyncData` | `EncryptedVault` |
|
||||
| `BrowserSessionData` | `UnlockedVaultState` |
|
||||
| `SignerMetaData` | `ExtensionSettings` |
|
||||
| `Identity_DECRYPTED` | `Identity` |
|
||||
| `Identity_ENCRYPTED` | `EncryptedIdentity` |
|
||||
|
||||
#### 5. Implicit Aggregate Boundaries
|
||||
|
||||
**Problem:** No clear aggregate roots. External code can manipulate any data:
|
||||
|
||||
```typescript
|
||||
// Anyone can reach into session data
|
||||
const identity = this.#browserSessionHandler.getIdentity(id);
|
||||
identity.nick = "changed"; // No invariant protection!
|
||||
```
|
||||
|
||||
**Should Have:** Aggregate roots as single entry points with invariant protection.
|
||||
|
||||
#### 6. TypeScript Union Type Issues
|
||||
|
||||
**Problem:** `LockedVaultContext` uses optional fields instead of discriminated unions:
|
||||
|
||||
```typescript
|
||||
// Current: Confusing optional fields
|
||||
type LockedVaultContext =
|
||||
| { iv: string; password: string; keyBase64?: undefined }
|
||||
| { iv: string; keyBase64: string; password?: undefined };
|
||||
|
||||
// Better: Discriminated union
|
||||
type LockedVaultContext =
|
||||
| { version: 1; iv: string; password: string }
|
||||
| { version: 2; iv: string; keyBase64: string };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recommended Domain Model
|
||||
|
||||
### Aggregate Design
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ AGGREGATE MAP │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Vault Aggregate (Root: Vault) │ │
|
||||
│ │ │ │
|
||||
│ │ Vault ──────┬──► Identity[] (child entities) │ │
|
||||
│ │ ├──► Permission[] (child entities) │ │
|
||||
│ │ ├──► Relay[] (child entities) │ │
|
||||
│ │ ├──► NwcConnection[] (child entities) │ │
|
||||
│ │ └──► CashuMint[] (child entities) │ │
|
||||
│ │ │ │
|
||||
│ │ Invariants: │ │
|
||||
│ │ - At most one identity can be selected │ │
|
||||
│ │ - Permissions must reference existing identities │ │
|
||||
│ │ - Relays must reference existing identities │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ ExtensionSettings Aggregate (Root: ExtensionSettings) │ │
|
||||
│ │ │ │
|
||||
│ │ ExtensionSettings ──┬──► SyncPreference │ │
|
||||
│ │ ├──► SecurityPolicy (reckless, whitelist)│ │
|
||||
│ │ ├──► Bookmark[] │ │
|
||||
│ │ └──► VaultSnapshot[] │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ ProfileCache Aggregate (Root: ProfileCache) │ │
|
||||
│ │ │ │
|
||||
│ │ ProfileCache ──► ProfileMetadata[] │ │
|
||||
│ │ │ │
|
||||
│ │ Invariants: │ │
|
||||
│ │ - Entries expire after TTL │ │
|
||||
│ │ - One entry per pubkey │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Value Objects
|
||||
|
||||
```typescript
|
||||
// Strongly-typed identity
|
||||
class IdentityId {
|
||||
private constructor(private readonly value: string) {}
|
||||
static generate(): IdentityId { return new IdentityId(uuid()); }
|
||||
static from(value: string): IdentityId { return new IdentityId(value); }
|
||||
equals(other: IdentityId): boolean { return this.value === other.value; }
|
||||
toString(): string { return this.value; }
|
||||
}
|
||||
|
||||
// Self-validating nickname
|
||||
class Nickname {
|
||||
private constructor(private readonly value: string) {}
|
||||
static create(value: string): Nickname {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new InvalidNicknameError(value);
|
||||
}
|
||||
return new Nickname(value.trim());
|
||||
}
|
||||
toString(): string { return this.value; }
|
||||
}
|
||||
|
||||
// Nostr key pair encapsulation
|
||||
class NostrKeyPair {
|
||||
private constructor(
|
||||
private readonly privateKeyHex: string,
|
||||
private readonly publicKeyHex: string
|
||||
) {}
|
||||
|
||||
static fromPrivateKey(privkey: string): NostrKeyPair {
|
||||
const hex = privkey.startsWith('nsec')
|
||||
? NostrHelper.nsecToHex(privkey)
|
||||
: privkey;
|
||||
const pubkey = NostrHelper.pubkeyFromPrivkey(hex);
|
||||
return new NostrKeyPair(hex, pubkey);
|
||||
}
|
||||
|
||||
get publicKey(): string { return this.publicKeyHex; }
|
||||
get npub(): string { return NostrHelper.pubkey2npub(this.publicKeyHex); }
|
||||
|
||||
sign(event: UnsignedEvent): SignedEvent {
|
||||
return NostrHelper.signEvent(event, this.privateKeyHex);
|
||||
}
|
||||
|
||||
encrypt(plaintext: string, recipientPubkey: string, version: 4 | 44): string {
|
||||
return version === 4
|
||||
? NostrHelper.nip04Encrypt(plaintext, this.privateKeyHex, recipientPubkey)
|
||||
: NostrHelper.nip44Encrypt(plaintext, this.privateKeyHex, recipientPubkey);
|
||||
}
|
||||
}
|
||||
|
||||
// Permission policy
|
||||
class PermissionPolicy {
|
||||
private constructor(
|
||||
private readonly identityId: IdentityId,
|
||||
private readonly host: string,
|
||||
private readonly method: Nip07Method,
|
||||
private readonly decision: 'allow' | 'deny',
|
||||
private readonly kind?: number
|
||||
) {}
|
||||
|
||||
static allow(identityId: IdentityId, host: string, method: Nip07Method, kind?: number): PermissionPolicy {
|
||||
return new PermissionPolicy(identityId, host, method, 'allow', kind);
|
||||
}
|
||||
|
||||
static deny(identityId: IdentityId, host: string, method: Nip07Method, kind?: number): PermissionPolicy {
|
||||
return new PermissionPolicy(identityId, host, method, 'deny', kind);
|
||||
}
|
||||
|
||||
matches(identityId: IdentityId, host: string, method: Nip07Method, kind?: number): boolean {
|
||||
return this.identityId.equals(identityId)
|
||||
&& this.host === host
|
||||
&& this.method === method
|
||||
&& (this.kind === undefined || this.kind === kind);
|
||||
}
|
||||
|
||||
isAllowed(): boolean { return this.decision === 'allow'; }
|
||||
}
|
||||
```
|
||||
|
||||
### Rich Domain Entities
|
||||
|
||||
```typescript
|
||||
class Identity {
|
||||
private readonly _id: IdentityId;
|
||||
private _nickname: Nickname;
|
||||
private readonly _keyPair: NostrKeyPair;
|
||||
private readonly _createdAt: Date;
|
||||
private _domainEvents: DomainEvent[] = [];
|
||||
|
||||
private constructor(
|
||||
id: IdentityId,
|
||||
nickname: Nickname,
|
||||
keyPair: NostrKeyPair,
|
||||
createdAt: Date
|
||||
) {
|
||||
this._id = id;
|
||||
this._nickname = nickname;
|
||||
this._keyPair = keyPair;
|
||||
this._createdAt = createdAt;
|
||||
}
|
||||
|
||||
static create(nickname: string, privateKey?: string): Identity {
|
||||
const keyPair = privateKey
|
||||
? NostrKeyPair.fromPrivateKey(privateKey)
|
||||
: NostrKeyPair.generate();
|
||||
|
||||
const identity = new Identity(
|
||||
IdentityId.generate(),
|
||||
Nickname.create(nickname),
|
||||
keyPair,
|
||||
new Date()
|
||||
);
|
||||
|
||||
identity._domainEvents.push(new IdentityCreated(identity._id, identity.publicKey));
|
||||
return identity;
|
||||
}
|
||||
|
||||
get id(): IdentityId { return this._id; }
|
||||
get publicKey(): string { return this._keyPair.publicKey; }
|
||||
get npub(): string { return this._keyPair.npub; }
|
||||
get nickname(): string { return this._nickname.toString(); }
|
||||
|
||||
rename(newNickname: string): void {
|
||||
const oldNickname = this._nickname.toString();
|
||||
this._nickname = Nickname.create(newNickname);
|
||||
this._domainEvents.push(new IdentityRenamed(this._id, oldNickname, newNickname));
|
||||
}
|
||||
|
||||
sign(event: UnsignedEvent): SignedEvent {
|
||||
return this._keyPair.sign(event);
|
||||
}
|
||||
|
||||
encrypt(plaintext: string, recipientPubkey: string, version: 4 | 44): string {
|
||||
return this._keyPair.encrypt(plaintext, recipientPubkey, version);
|
||||
}
|
||||
|
||||
pullDomainEvents(): DomainEvent[] {
|
||||
const events = [...this._domainEvents];
|
||||
this._domainEvents = [];
|
||||
return events;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Refactoring Roadmap
|
||||
|
||||
### Phase 1: Extract Value Objects (Low Risk)
|
||||
|
||||
**Goal:** Introduce type safety without changing behavior.
|
||||
|
||||
1. Create `IdentityId`, `Nickname`, `NostrKeyPair` value objects
|
||||
2. Use them in existing interfaces initially
|
||||
3. Add validation in factory methods
|
||||
4. Update helpers to use value objects
|
||||
|
||||
**Files to Modify:**
|
||||
- Create `projects/common/src/lib/domain/value-objects/`
|
||||
- Update `projects/common/src/lib/helpers/nostr-helper.ts`
|
||||
|
||||
### Phase 2: Introduce Repository Pattern (Medium Risk)
|
||||
|
||||
**Goal:** Separate storage concerns from domain logic.
|
||||
|
||||
1. Define repository interfaces in domain layer
|
||||
2. Create `IdentityRepository`, `PermissionRepository`, etc.
|
||||
3. Move encryption to `EncryptionService` infrastructure
|
||||
4. Refactor `StorageService` to delegate to repositories
|
||||
|
||||
**New Structure:**
|
||||
```
|
||||
projects/common/src/lib/
|
||||
├── domain/
|
||||
│ ├── identity/
|
||||
│ │ ├── Identity.ts
|
||||
│ │ ├── IdentityRepository.ts (interface)
|
||||
│ │ └── events/
|
||||
│ ├── permission/
|
||||
│ │ ├── PermissionPolicy.ts
|
||||
│ │ └── PermissionRepository.ts (interface)
|
||||
│ └── vault/
|
||||
│ ├── Vault.ts
|
||||
│ └── VaultRepository.ts (interface)
|
||||
├── infrastructure/
|
||||
│ ├── encryption/
|
||||
│ │ └── EncryptionService.ts
|
||||
│ └── persistence/
|
||||
│ ├── ChromeIdentityRepository.ts
|
||||
│ └── FirefoxIdentityRepository.ts
|
||||
└── application/
|
||||
├── IdentityApplicationService.ts
|
||||
└── VaultApplicationService.ts
|
||||
```
|
||||
|
||||
### Phase 3: Rich Domain Model (Higher Risk)
|
||||
|
||||
**Goal:** Move behavior into domain entities.
|
||||
|
||||
1. Convert `Identity_DECRYPTED` interface to `Identity` class
|
||||
2. Move signing logic into `Identity.sign()`
|
||||
3. Move encryption decision logic into domain
|
||||
4. Add domain events for state changes
|
||||
|
||||
### Phase 4: Ubiquitous Language Cleanup
|
||||
|
||||
**Goal:** Align code with domain language.
|
||||
|
||||
| Old Name | New Name |
|
||||
|----------|----------|
|
||||
| `BrowserSyncData` | `EncryptedVault` |
|
||||
| `BrowserSessionData` | `VaultSession` |
|
||||
| `SignerMetaData` | `ExtensionSettings` |
|
||||
| `StorageService` | `VaultService` (or split into multiple) |
|
||||
| `addIdentity()` | `Identity.create()` + `IdentityRepository.save()` |
|
||||
| `switchIdentity()` | `Vault.selectIdentity()` |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priorities
|
||||
|
||||
### High Priority (Security/Correctness)
|
||||
|
||||
1. **Encapsulate KeyPair operations** - Private keys should never be accessed directly
|
||||
2. **Enforce invariants** - Selected identity must exist, permissions must reference valid identities
|
||||
3. **Clear transaction boundaries** - What gets saved together?
|
||||
|
||||
### Medium Priority (Maintainability)
|
||||
|
||||
1. **Split StorageService** - Into VaultService, IdentityRepository, PermissionRepository
|
||||
2. **Extract EncryptionService** - Pure infrastructure concern
|
||||
3. **Type-safe IDs** - Prevent mixing up identity IDs with permission IDs
|
||||
|
||||
### Lower Priority (Polish)
|
||||
|
||||
1. **Domain events** - For audit trail and extensibility
|
||||
2. **Full ubiquitous language** - Rename all types
|
||||
3. **Discriminated unions** - For vault context types
|
||||
|
||||
---
|
||||
|
||||
## Testing Implications
|
||||
|
||||
Current state makes testing difficult because:
|
||||
- `StorageService` requires mocking 4 handlers
|
||||
- Encryption is interleaved with logic
|
||||
- No clear boundaries to test in isolation
|
||||
|
||||
With proposed changes:
|
||||
- Domain entities testable in isolation (no storage mocks)
|
||||
- Repositories testable with in-memory implementations
|
||||
- Clear separation enables focused unit tests
|
||||
|
||||
```typescript
|
||||
// Example: Testing Identity domain logic
|
||||
describe('Identity', () => {
|
||||
it('signs events with internal keypair', () => {
|
||||
const identity = Identity.create('Test', 'nsec1...');
|
||||
const event = { kind: 1, content: 'test', /* ... */ };
|
||||
|
||||
const signed = identity.sign(event);
|
||||
|
||||
expect(signed.sig).toBeDefined();
|
||||
expect(signed.pubkey).toBe(identity.publicKey);
|
||||
});
|
||||
|
||||
it('prevents duplicate private keys via repository', async () => {
|
||||
const repository = new InMemoryIdentityRepository();
|
||||
const existing = Identity.create('First', 'nsec1abc...');
|
||||
await repository.save(existing);
|
||||
|
||||
const duplicate = Identity.create('Second', 'nsec1abc...');
|
||||
|
||||
await expect(repository.save(duplicate))
|
||||
.rejects.toThrow(DuplicateIdentityError);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
The Plebeian Signer codebase has solid foundations but would benefit significantly from DDD tactical patterns. The recommended approach:
|
||||
|
||||
1. **Start with value objects** - Low risk, immediate type safety benefits
|
||||
2. **Introduce repositories gradually** - Extract one at a time, starting with Identity
|
||||
3. **Defer full rich domain model** - Until repositories stabilize the architecture
|
||||
4. **Update language as you go** - Rename types when touching files anyway
|
||||
|
||||
The goal is not architectural purity but **maintainability, testability, and security**. DDD patterns are a means to those ends in a domain (cryptographic identity management) where correctness matters.
|
||||
24
LICENSE
Normal file
@@ -0,0 +1,24 @@
|
||||
This is free and unencumbered software released into the public domain.
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||
distribute this software, either in source code form or as a compiled
|
||||
binary, for any purpose, commercial or non-commercial, and by any
|
||||
means.
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors
|
||||
of this software dedicate any and all copyright interest in the
|
||||
software to the public domain. We make this dedication for the benefit
|
||||
of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of
|
||||
relinquishment in perpetuity of all present and future rights to this
|
||||
software under copyright law.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
For more information, please refer to <http://unlicense.org/>
|
||||
68
PRIVACY_POLICY.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Privacy Policy
|
||||
|
||||
**Plebeian Signer** is a browser extension for managing Nostr identities and signing events. This privacy policy explains how the extension handles your data.
|
||||
|
||||
## Data Collection
|
||||
|
||||
**Plebeian Signer does not collect, store, or transmit any user data to external servers.**
|
||||
|
||||
All data remains on your device under your control.
|
||||
|
||||
## Data Storage
|
||||
|
||||
The extension stores the following data locally in your browser:
|
||||
|
||||
- **Encrypted vault**: Your Nostr private keys, encrypted with your password using Argon2id + AES-256-GCM
|
||||
- **Identity metadata**: Display names, profile information you configure
|
||||
- **Permissions**: Your allow/deny decisions for websites
|
||||
- **Cashu wallet data**: Mint connections and ecash tokens you store
|
||||
- **Preferences**: Extension settings (sync mode, reckless mode, etc.)
|
||||
|
||||
This data is stored using your browser's built-in storage APIs and never leaves your device unless you enable browser sync (in which case it syncs through your browser's own sync service, not ours).
|
||||
|
||||
## External Connections
|
||||
|
||||
The extension only makes external network requests in the following cases:
|
||||
|
||||
1. **Cashu mints**: When you explicitly add a Cashu mint and perform wallet operations (deposit, send, receive), the extension connects to that mint's URL. You choose which mints to connect to.
|
||||
|
||||
2. **No other external connections**: The extension does not connect to any analytics services, tracking pixels, telemetry endpoints, or any servers operated by the developers.
|
||||
|
||||
## Third-Party Services
|
||||
|
||||
Plebeian Signer does not integrate with any third-party services. The only external services involved are:
|
||||
|
||||
- **Cashu mints**: User-configured ecash mints for wallet functionality
|
||||
- **Browser sync** (optional): Your browser's native sync service if you enable vault syncing
|
||||
|
||||
## Data Sharing
|
||||
|
||||
We do not share any data because we do not have access to any data. Your private keys and all extension data remain encrypted on your device.
|
||||
|
||||
## Security
|
||||
|
||||
- Private keys are encrypted at rest using Argon2id key derivation and AES-256-GCM encryption
|
||||
- Keys are never exposed to websites — only signatures are provided
|
||||
- The vault locks automatically and requires your password to unlock
|
||||
|
||||
## Your Rights
|
||||
|
||||
Since all data is stored locally on your device:
|
||||
|
||||
- **Access**: View your data anytime in the extension
|
||||
- **Delete**: Uninstall the extension or clear browser data to remove all stored data
|
||||
- **Export**: Use the extension's export features to backup your data
|
||||
|
||||
## Changes to This Policy
|
||||
|
||||
Any changes to this privacy policy will be reflected in the extension's repository and release notes.
|
||||
|
||||
## Contact
|
||||
|
||||
For questions about this privacy policy, please open an issue at the project repository.
|
||||
|
||||
---
|
||||
|
||||
**Last updated**: January 2026
|
||||
|
||||
**Extension**: Plebeian Signer v1.1.5
|
||||
@@ -28,7 +28,7 @@ The repository is configured as monorepo to hold the extensions for Chrome and F
|
||||
To build and run the Chrome extension from this code:
|
||||
|
||||
```
|
||||
git clone https://git.mleku.dev/mleku/plebeian-signer
|
||||
git clone https://github.com/PlebeianApp/plebeian-signer.git
|
||||
cd plebeian-signer
|
||||
npm ci
|
||||
npm run build:chrome
|
||||
@@ -46,7 +46,7 @@ then
|
||||
To build and run the Firefox extension from this code:
|
||||
|
||||
```
|
||||
git clone https://git.mleku.dev/mleku/plebeian-signer
|
||||
git clone https://github.com/PlebeianApp/plebeian-signer.git
|
||||
cd plebeian-signer
|
||||
npm ci
|
||||
npm run build:firefox
|
||||
|
||||
@@ -51,8 +51,8 @@
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
"maximumWarning": "25kB",
|
||||
"maximumError": "30kB"
|
||||
}
|
||||
],
|
||||
"optimization": {
|
||||
@@ -154,8 +154,8 @@
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
"maximumWarning": "25kB",
|
||||
"maximumError": "30kB"
|
||||
}
|
||||
],
|
||||
"optimization": {
|
||||
|
||||
@@ -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://github.com/PlebeianApp/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://github.com/PlebeianApp/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://github.com/PlebeianApp/plebeian-signer/blob/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://github.com/PlebeianApp/plebeian-signer`
|
||||
- **Support URL:** `https://github.com/PlebeianApp/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://github.com/PlebeianApp/plebeian-signer`
|
||||
- **Support URL:** `https://github.com/PlebeianApp/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://github.com/PlebeianApp/plebeian-signer
|
||||
- Report Issues: https://github.com/PlebeianApp/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://github.com/PlebeianApp/plebeian-signer/blob/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://github.com/PlebeianApp/plebeian-signer/blob/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://github.com/PlebeianApp/plebeian-signer` |
|
||||
| Support URL | `https://github.com/PlebeianApp/plebeian-signer/issues` |
|
||||
| Privacy Policy | `https://github.com/PlebeianApp/plebeian-signer/blob/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
|
||||
|
||||
|
||||
336
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "plebian-signer",
|
||||
"version": "0.0.4",
|
||||
"name": "plebeian-signer",
|
||||
"version": "1.2.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "plebian-signer",
|
||||
"version": "0.0.4",
|
||||
"name": "plebeian-signer",
|
||||
"version": "1.2.2",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^19.0.0",
|
||||
"@angular/common": "^19.0.0",
|
||||
@@ -16,12 +16,16 @@
|
||||
"@angular/platform-browser": "^19.0.0",
|
||||
"@angular/platform-browser-dynamic": "^19.0.0",
|
||||
"@angular/router": "^19.0.0",
|
||||
"@cashu/cashu-ts": "^3.2.0",
|
||||
"@nostr-dev-kit/ndk": "^2.11.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"bootstrap": "^5.3.3",
|
||||
"bootstrap-icons": "^1.11.3",
|
||||
"buffer": "^6.0.3",
|
||||
"hash-wasm": "^4.11.0",
|
||||
"nostr-tools": "^2.10.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"webextension-polyfill": "^0.12.0",
|
||||
@@ -35,6 +39,7 @@
|
||||
"@types/bootstrap": "^5.2.10",
|
||||
"@types/chrome": "^0.0.293",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/webextension-polyfill": "^0.12.1",
|
||||
"angular-eslint": "19.0.2",
|
||||
"eslint": "^9.16.0",
|
||||
@@ -4711,6 +4716,70 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cashu/cashu-ts": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@cashu/cashu-ts/-/cashu-ts-3.2.0.tgz",
|
||||
"integrity": "sha512-wOdqenmPs92+5feU2GIg92QcdNmCdg4AIau7Lq6G/uw1t+t/osjygupr2dmDzdQx7JBWHHNoVaUDSJm1G8phYg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "^2.0.1",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@scure/bip32": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cashu/cashu-ts/node_modules/@noble/curves": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
|
||||
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@cashu/cashu-ts/node_modules/@noble/hashes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@cashu/cashu-ts/node_modules/@scure/base": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz",
|
||||
"integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@cashu/cashu-ts/node_modules/@scure/bip32": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.0.1.tgz",
|
||||
"integrity": "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "2.0.1",
|
||||
"@noble/hashes": "2.0.1",
|
||||
"@scure/base": "2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@colors/colors": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
|
||||
@@ -8156,7 +8225,6 @@
|
||||
"version": "22.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.0.tgz",
|
||||
"integrity": "sha512-ClIbNe36lawluuvq3+YYhnIN2CELi+6q8NpnM7PYp4hBn/TatfboPgVSm2rwKRfnV2M+Ty9GWDFI64KEe+kysA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.20.0"
|
||||
@@ -8172,6 +8240,15 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.9.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz",
|
||||
@@ -8236,6 +8313,13 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/webextension-polyfill": {
|
||||
"version": "0.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/webextension-polyfill/-/webextension-polyfill-0.12.1.tgz",
|
||||
@@ -9244,7 +9328,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -9924,6 +10007,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001696",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001696.tgz",
|
||||
@@ -10191,7 +10283,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -10204,7 +10295,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
@@ -10642,6 +10732,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -10771,6 +10870,12 @@
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dns-packet": {
|
||||
"version": "5.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
|
||||
@@ -12082,7 +12187,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
@@ -12320,6 +12424,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",
|
||||
@@ -16117,7 +16227,6 @@
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -16266,7 +16375,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -16488,6 +16596,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.4.49",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
|
||||
@@ -16737,6 +16854,177 @@
|
||||
"node": ">=0.9"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.13.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||
@@ -16945,7 +17233,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -16961,6 +17248,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
@@ -17639,6 +17932,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -19017,7 +19316,6 @@
|
||||
"version": "6.20.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
||||
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||
@@ -19849,6 +20147,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wildcard": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz",
|
||||
@@ -19870,7 +20174,6 @@
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
@@ -19959,7 +20262,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -19969,14 +20271,12 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -19986,7 +20286,6 @@
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@@ -20001,7 +20300,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
|
||||
19
package.json
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "plebeian-signer",
|
||||
"version": "v0.0.8",
|
||||
"version": "1.2.2",
|
||||
"custom": {
|
||||
"chrome": {
|
||||
"version": "v0.0.8"
|
||||
"version": "v1.1.6"
|
||||
},
|
||||
"firefox": {
|
||||
"version": "v0.0.8"
|
||||
"version": "v1.1.6"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -35,12 +36,16 @@
|
||||
"@angular/platform-browser": "^19.0.0",
|
||||
"@angular/platform-browser-dynamic": "^19.0.0",
|
||||
"@angular/router": "^19.0.0",
|
||||
"@cashu/cashu-ts": "^3.2.0",
|
||||
"@nostr-dev-kit/ndk": "^2.11.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"bootstrap": "^5.3.3",
|
||||
"bootstrap-icons": "^1.11.3",
|
||||
"buffer": "^6.0.3",
|
||||
"hash-wasm": "^4.11.0",
|
||||
"nostr-tools": "^2.10.4",
|
||||
"qrcode": "^1.5.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"webextension-polyfill": "^0.12.0",
|
||||
@@ -54,6 +59,7 @@
|
||||
"@types/bootstrap": "^5.2.10",
|
||||
"@types/chrome": "^0.0.293",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/webextension-polyfill": "^0.12.1",
|
||||
"angular-eslint": "19.0.2",
|
||||
"eslint": "^9.16.0",
|
||||
@@ -68,5 +74,6 @@
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "8.18.0"
|
||||
}
|
||||
},
|
||||
"license": "Unlicense"
|
||||
}
|
||||
|
||||
BIN
plebeian-signer-chrome-v1.2.2.zip
Normal file
BIN
plebeian-signer-firefox-v1.2.2.zip
Normal file
@@ -22,5 +22,9 @@ module.exports = {
|
||||
import: 'src/options.ts',
|
||||
runtime: false,
|
||||
},
|
||||
unlock: {
|
||||
import: 'src/unlock.ts',
|
||||
runtime: false,
|
||||
},
|
||||
},
|
||||
} as Configuration;
|
||||
|
||||
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": "v0.0.8",
|
||||
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
|
||||
"version": "1.1.6",
|
||||
"homepage_url": "https://github.com/PlebeianApp/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 |
@@ -27,11 +27,66 @@
|
||||
.page {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: 1fr 60px;
|
||||
grid-template-rows: 1fr auto;
|
||||
grid-template-columns: 1fr;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: var(--size);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-label {
|
||||
width: 60px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.action-buttons button {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-reject {
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.btn-reject:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.btn-accept {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.btn-accept:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: var(--size);
|
||||
background: var(--background-light);
|
||||
@@ -54,6 +109,12 @@
|
||||
font-size: 12px;
|
||||
color: gray;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -63,64 +124,31 @@
|
||||
<span id="titleSpan" style="font-weight: 400 !important"></span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="host-INSERT sam-align-self-center sam-text-muted"
|
||||
style="font-weight: 500"
|
||||
></span>
|
||||
|
||||
<!-- Card for getPublicKey -->
|
||||
<div id="cardGetPublicKey" class="card sam-mt sam-ml sam-mr">
|
||||
<span style="text-align: center">
|
||||
<b><span class="host-INSERT color-primary"></span></b>
|
||||
is requesting permission to<br />
|
||||
<br />
|
||||
<b class="color-primary">read your public key</b> <br />
|
||||
<br />
|
||||
<span>
|
||||
for the selected identity
|
||||
<span
|
||||
style="font-weight: 500"
|
||||
class="nick-INSERT color-primary"
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
<p class="description">
|
||||
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||
<b class="color-primary">read your public key</b> for the selected identity
|
||||
<b class="nick-INSERT color-primary"></b>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card for getRelays -->
|
||||
<div id="cardGetRelays" class="card sam-mt sam-ml sam-mr">
|
||||
<span style="text-align: center">
|
||||
<b><span class="host-INSERT color-primary"></span></b>
|
||||
is requesting permission to<br />
|
||||
<br />
|
||||
<b class="color-primary">read your relays</b> <br />
|
||||
<br />
|
||||
<span>
|
||||
for the selected identity
|
||||
<span
|
||||
style="font-weight: 500"
|
||||
class="nick-INSERT color-primary"
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
<p class="description">
|
||||
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||
<b class="color-primary">read your relays</b> for the selected identity
|
||||
<b class="nick-INSERT color-primary"></b>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card for signEvent -->
|
||||
<div id="cardSignEvent" class="card sam-mt sam-ml sam-mr">
|
||||
<span style="text-align: center">
|
||||
<b><span class="host-INSERT color-primary"></span></b>
|
||||
is requesting permission to<br />
|
||||
<br />
|
||||
<b class="color-primary">sign an event</b> (kind
|
||||
<span id="kindSpan"></span>) <br />
|
||||
<br />
|
||||
<span>
|
||||
for the selected identity
|
||||
<span
|
||||
style="font-weight: 500"
|
||||
class="nick-INSERT color-primary"
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
<p class="description">
|
||||
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||
<b class="color-primary">sign an event</b> (kind <span id="kindSpan"></span>)
|
||||
for the selected identity <b class="nick-INSERT color-primary"></b>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card2 for signEvent -->
|
||||
@@ -130,20 +158,11 @@
|
||||
|
||||
<!-- Card for nip04.encrypt -->
|
||||
<div id="cardNip04Encrypt" class="card sam-mt sam-ml sam-mr">
|
||||
<span style="text-align: center">
|
||||
<b><span class="host-INSERT color-primary"></span></b>
|
||||
is requesting permission to<br />
|
||||
<br />
|
||||
<b class="color-primary">encrypt a text</b> (NIP04) <br />
|
||||
<br />
|
||||
<span>
|
||||
for the selected identity
|
||||
<span
|
||||
style="font-weight: 500"
|
||||
class="nick-INSERT color-primary"
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
<p class="description">
|
||||
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||
<b class="color-primary">encrypt a text</b> (NIP04) for the selected identity
|
||||
<b class="nick-INSERT color-primary"></b>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card2 for nip04.encrypt -->
|
||||
@@ -153,20 +172,11 @@
|
||||
|
||||
<!-- Card for nip44.encrypt -->
|
||||
<div id="cardNip44Encrypt" class="card sam-mt sam-ml sam-mr">
|
||||
<span style="text-align: center">
|
||||
<b><span class="host-INSERT color-primary"></span></b>
|
||||
is requesting permission to<br />
|
||||
<br />
|
||||
<b class="color-primary">encrypt a text</b> (NIP44) <br />
|
||||
<br />
|
||||
<span>
|
||||
for the selected identity
|
||||
<span
|
||||
style="font-weight: 500"
|
||||
class="nick-INSERT color-primary"
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
<p class="description">
|
||||
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||
<b class="color-primary">encrypt a text</b> (NIP44) for the selected identity
|
||||
<b class="nick-INSERT color-primary"></b>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card2 for nip44.encrypt -->
|
||||
@@ -176,20 +186,11 @@
|
||||
|
||||
<!-- Card for nip04.decrypt -->
|
||||
<div id="cardNip04Decrypt" class="card sam-mt sam-ml sam-mr">
|
||||
<span style="text-align: center">
|
||||
<b><span class="host-INSERT color-primary"></span></b>
|
||||
is requesting permission to<br />
|
||||
<br />
|
||||
<b class="color-primary">decrypt a text</b> (NIP04) <br />
|
||||
<br />
|
||||
<span>
|
||||
for the selected identity
|
||||
<span
|
||||
style="font-weight: 500"
|
||||
class="nick-INSERT color-primary"
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
<p class="description">
|
||||
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||
<b class="color-primary">decrypt a text</b> (NIP04) for the selected identity
|
||||
<b class="nick-INSERT color-primary"></b>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card2 for nip04.decrypt -->
|
||||
@@ -199,72 +200,90 @@
|
||||
|
||||
<!-- Card for nip44.decrypt -->
|
||||
<div id="cardNip44Decrypt" class="card sam-mt sam-ml sam-mr">
|
||||
<span style="text-align: center">
|
||||
<b><span class="host-INSERT color-primary"></span></b>
|
||||
is requesting permission to<br />
|
||||
<br />
|
||||
<b class="color-primary">decrypt a text</b> (NIP44) <br />
|
||||
<br />
|
||||
<span>
|
||||
for the selected identity
|
||||
<span
|
||||
style="font-weight: 500"
|
||||
class="nick-INSERT color-primary"
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
<p class="description">
|
||||
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||
<b class="color-primary">decrypt a text</b> (NIP44) for the selected identity
|
||||
<b class="nick-INSERT color-primary"></b>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card2 for nip44.decrypt -->
|
||||
<div id="card2Nip44Decrypt" class="card sam-mt sam-ml sam-mr">
|
||||
<div id="card2Nip44Decrypt_text" class="text"></div>
|
||||
</div>
|
||||
|
||||
<!-- Card for webln.enable -->
|
||||
<div id="cardWeblnEnable" class="card sam-mt sam-ml sam-mr">
|
||||
<p class="description">
|
||||
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||
<b class="color-primary">connect to your Lightning wallet</b>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card for webln.getInfo -->
|
||||
<div id="cardWeblnGetInfo" class="card sam-mt sam-ml sam-mr">
|
||||
<p class="description">
|
||||
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||
<b class="color-primary">read your wallet info</b>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card for webln.sendPayment -->
|
||||
<div id="cardWeblnSendPayment" class="card sam-mt sam-ml sam-mr">
|
||||
<p class="description">
|
||||
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||
<b class="color-primary">send a Lightning payment</b> of
|
||||
<b id="paymentAmountSpan" class="color-primary"></b>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card2 for webln.sendPayment (shows invoice) -->
|
||||
<div id="card2WeblnSendPayment" class="card sam-mt sam-ml sam-mr">
|
||||
<div id="card2WeblnSendPayment_json" class="json"></div>
|
||||
</div>
|
||||
|
||||
<!-- Card for webln.makeInvoice -->
|
||||
<div id="cardWeblnMakeInvoice" class="card sam-mt sam-ml sam-mr">
|
||||
<p class="description">
|
||||
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||
<b class="color-primary">create a Lightning invoice</b>
|
||||
<span id="invoiceAmountSpan"></span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Card for webln.keysend -->
|
||||
<div id="cardWeblnKeysend" class="card sam-mt sam-ml sam-mr">
|
||||
<p class="description">
|
||||
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||
<b class="color-primary">send a keysend payment</b>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!------------->
|
||||
<!-- ACTIONS -->
|
||||
<!------------->
|
||||
<div class="sam-footer-grid-2">
|
||||
<div class="btn-group">
|
||||
<button id="rejectButton" type="button" class="btn btn-secondary">
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary dropdown-toggle dropdown-toggle-split"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span class="visually-hidden">Toggle Dropdown</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<button id="rejectJustOnceButton" class="dropdown-item">
|
||||
just once
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="actions">
|
||||
<div class="action-row">
|
||||
<span class="action-label">Reject</span>
|
||||
<div class="action-buttons">
|
||||
<button id="rejectOnceButton" type="button" class="btn-reject">Once</button>
|
||||
<button id="rejectAlwaysButton" type="button" class="btn-reject">Always</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button id="approveButton" type="button" class="btn btn-primary">
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary dropdown-toggle dropdown-toggle-split"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span class="visually-hidden">Toggle Dropdown</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<button id="approveJustOnceButton" class="dropdown-item" href="#">
|
||||
just once
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="action-row">
|
||||
<span class="action-label">Accept</span>
|
||||
<div class="action-buttons">
|
||||
<button id="approveOnceButton" type="button" class="btn-accept">Once</button>
|
||||
<button id="approveAlwaysButton" type="button" class="btn-accept">Always</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-row" id="allQueuedRow">
|
||||
<span class="action-label">All Queued</span>
|
||||
<div class="action-buttons">
|
||||
<button id="rejectAllButton" type="button" class="btn-reject">Reject All</button>
|
||||
<button id="approveAllButton" type="button" class="btn-accept">Approve All</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
245
projects/chrome/public/unlock.html
Normal file
@@ -0,0 +1,245 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<title>Plebeian Signer - Unlock</title>
|
||||
<link rel="stylesheet" type="text/css" href="styles.css" />
|
||||
<script src="scripts.js"></script>
|
||||
<style>
|
||||
/* Prevent white flash on load */
|
||||
html { background-color: #0a0a0a; }
|
||||
@media (prefers-color-scheme: light) {
|
||||
html { background-color: #ffffff; }
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
color: var(--foreground);
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.color-primary {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--size);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
padding: var(--size) 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.logo-frame {
|
||||
border: 2px solid var(--secondary);
|
||||
border-radius: 100%;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logo-frame img {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-right: none;
|
||||
border-radius: 6px 0 0 6px;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.input-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.input-group button {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0 6px 6px 0;
|
||||
background: var(--background-light);
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.input-group button:hover {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.unlock-btn {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.unlock-btn:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.unlock-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.alert {
|
||||
position: fixed;
|
||||
bottom: var(--size);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 10px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background: var(--destructive);
|
||||
color: var(--destructive-foreground);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.deriving-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--muted);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.deriving-text {
|
||||
color: var(--foreground);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.host-info {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--muted-foreground);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.host-name {
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="header">
|
||||
<span class="brand">Plebeian Signer</span>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="logo-frame">
|
||||
<img src="logo.svg" height="100" width="100" alt="" />
|
||||
</div>
|
||||
|
||||
<div id="hostInfo" class="host-info hidden">
|
||||
<span class="host-name" id="hostSpan"></span><br>
|
||||
is requesting access
|
||||
</div>
|
||||
|
||||
<div class="input-group sam-mt">
|
||||
<input
|
||||
id="passwordInput"
|
||||
type="password"
|
||||
placeholder="vault password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<button id="togglePassword" type="button">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button id="unlockBtn" type="button" class="unlock-btn" disabled>
|
||||
<i class="bi bi-box-arrow-in-right"></i>
|
||||
<span>Unlock</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deriving overlay -->
|
||||
<div id="derivingOverlay" class="deriving-overlay hidden">
|
||||
<div class="spinner"></div>
|
||||
<div class="deriving-text">Unlocking vault...</div>
|
||||
</div>
|
||||
|
||||
<!-- Error alert -->
|
||||
<div id="errorAlert" class="alert alert-danger hidden">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
<span id="errorMessage">Invalid password</span>
|
||||
</div>
|
||||
|
||||
<script src="unlock.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,24 +4,26 @@ import { VaultLoginComponent } from './components/vault-login/vault-login.compon
|
||||
import { VaultCreateComponent } from './components/vault-create/vault-create.component';
|
||||
import { HomeComponent as VaultCreateHomeComponent } from './components/vault-create/home/home.component';
|
||||
import { NewComponent as VaultCreateNewComponent } from './components/vault-create/new/new.component';
|
||||
import { WelcomeComponent } from './components/welcome/welcome.component';
|
||||
import { IdentitiesComponent } from './components/home/identities/identities.component';
|
||||
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 { BackupsComponent } from './components/home/backups/backups.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';
|
||||
import { KeysComponent as EditIdentityKeysComponent } from './components/edit-identity/keys/keys.component';
|
||||
import { NcryptsecComponent as EditIdentityNcryptsecComponent } from './components/edit-identity/ncryptsec/ncryptsec.component';
|
||||
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 = [
|
||||
{
|
||||
path: 'welcome',
|
||||
component: WelcomeComponent,
|
||||
},
|
||||
{
|
||||
path: 'vault-login',
|
||||
component: VaultLoginComponent,
|
||||
@@ -64,12 +66,36 @@ export const routes: Routes = [
|
||||
path: 'settings',
|
||||
component: SettingsComponent,
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
component: LogsComponent,
|
||||
},
|
||||
{
|
||||
path: 'bookmarks',
|
||||
component: BookmarksComponent,
|
||||
},
|
||||
{
|
||||
path: 'wallet',
|
||||
component: WalletComponent,
|
||||
},
|
||||
{
|
||||
path: 'backups',
|
||||
component: BackupsComponent,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'new-identity',
|
||||
component: NewIdentityComponent,
|
||||
},
|
||||
{
|
||||
path: 'whitelisted-apps',
|
||||
component: WhitelistedAppsComponent,
|
||||
},
|
||||
{
|
||||
path: 'profile-edit',
|
||||
component: ProfileEditComponent,
|
||||
},
|
||||
{
|
||||
path: 'edit-identity/:id',
|
||||
component: EditIdentityComponent,
|
||||
@@ -82,6 +108,10 @@ export const routes: Routes = [
|
||||
path: 'keys',
|
||||
component: EditIdentityKeysComponent,
|
||||
},
|
||||
{
|
||||
path: 'ncryptsec',
|
||||
component: EditIdentityNcryptsecComponent,
|
||||
},
|
||||
{
|
||||
path: 'permissions',
|
||||
component: EditIdentityPermissionsComponent,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { SignerMetaData, SignerMetaHandler } from '@common';
|
||||
import { ExtensionSettings, SignerMetaHandler } from '@common';
|
||||
|
||||
export class ChromeMetaHandler extends SignerMetaHandler {
|
||||
async loadFullData(): Promise<Partial<Record<string, any>>> {
|
||||
@@ -19,7 +19,7 @@ export class ChromeMetaHandler extends SignerMetaHandler {
|
||||
return data;
|
||||
}
|
||||
|
||||
async saveFullData(data: SignerMetaData): Promise<void> {
|
||||
async saveFullData(data: ExtensionSettings): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { BrowserSessionData, BrowserSessionHandler } from '@common';
|
||||
import { VaultSession, BrowserSessionHandler } from '@common';
|
||||
|
||||
export class ChromeSessionHandler extends BrowserSessionHandler {
|
||||
async loadFullData(): Promise<Partial<Record<string, any>>> {
|
||||
return chrome.storage.session.get(null);
|
||||
}
|
||||
|
||||
async saveFullData(data: BrowserSessionData): Promise<void> {
|
||||
async saveFullData(data: VaultSession): Promise<void> {
|
||||
await chrome.storage.session.set(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
BrowserSyncData,
|
||||
EncryptedVault,
|
||||
BrowserSyncHandler,
|
||||
Identity_ENCRYPTED,
|
||||
Permission_ENCRYPTED,
|
||||
Relay_ENCRYPTED,
|
||||
StoredCashuMint,
|
||||
StoredIdentity,
|
||||
StoredNwcConnection,
|
||||
StoredPermission,
|
||||
StoredRelay,
|
||||
} from '@common';
|
||||
|
||||
/**
|
||||
@@ -24,20 +26,20 @@ export class ChromeSyncNoHandler extends BrowserSyncHandler {
|
||||
return data;
|
||||
}
|
||||
|
||||
async saveAndSetFullData(data: BrowserSyncData): Promise<void> {
|
||||
async saveAndSetFullData(data: EncryptedVault): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setFullData(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Permissions(data: {
|
||||
permissions: Permission_ENCRYPTED[];
|
||||
permissions: StoredPermission[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setPartialData_Permissions(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Identities(data: {
|
||||
identities: Identity_ENCRYPTED[];
|
||||
identities: StoredIdentity[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setPartialData_Identities(data);
|
||||
@@ -51,12 +53,26 @@ export class ChromeSyncNoHandler extends BrowserSyncHandler {
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Relays(data: {
|
||||
relays: Relay_ENCRYPTED[];
|
||||
relays: StoredRelay[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setPartialData_Relays(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_NwcConnections(data: {
|
||||
nwcConnections: StoredNwcConnection[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setPartialData_NwcConnections(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_CashuMints(data: {
|
||||
cashuMints: StoredCashuMint[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setPartialData_CashuMints(data);
|
||||
}
|
||||
|
||||
async clearData(): Promise<void> {
|
||||
const props = Object.keys(await this.loadUnmigratedData());
|
||||
await chrome.storage.local.remove(props);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
BrowserSyncData,
|
||||
Identity_ENCRYPTED,
|
||||
Permission_ENCRYPTED,
|
||||
EncryptedVault,
|
||||
StoredCashuMint,
|
||||
StoredIdentity,
|
||||
StoredNwcConnection,
|
||||
StoredPermission,
|
||||
BrowserSyncHandler,
|
||||
Relay_ENCRYPTED,
|
||||
StoredRelay,
|
||||
} from '@common';
|
||||
|
||||
/**
|
||||
@@ -16,20 +18,20 @@ export class ChromeSyncYesHandler extends BrowserSyncHandler {
|
||||
return await chrome.storage.sync.get(null);
|
||||
}
|
||||
|
||||
async saveAndSetFullData(data: BrowserSyncData): Promise<void> {
|
||||
async saveAndSetFullData(data: EncryptedVault): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setFullData(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Permissions(data: {
|
||||
permissions: Permission_ENCRYPTED[];
|
||||
permissions: StoredPermission[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setPartialData_Permissions(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Identities(data: {
|
||||
identities: Identity_ENCRYPTED[];
|
||||
identities: StoredIdentity[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setPartialData_Identities(data);
|
||||
@@ -43,12 +45,26 @@ export class ChromeSyncYesHandler extends BrowserSyncHandler {
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Relays(data: {
|
||||
relays: Relay_ENCRYPTED[];
|
||||
relays: StoredRelay[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setPartialData_Relays(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_NwcConnections(data: {
|
||||
nwcConnections: StoredNwcConnection[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setPartialData_NwcConnections(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_CashuMints(data: {
|
||||
cashuMints: StoredCashuMint[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setPartialData_CashuMints(data);
|
||||
}
|
||||
|
||||
async clearData(): Promise<void> {
|
||||
await chrome.storage.sync.clear();
|
||||
}
|
||||
|
||||
@@ -136,6 +136,12 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="sam-mt-2">Encrypted Key (NIP-49)</span>
|
||||
|
||||
<button class="btn btn-primary sam-mt-h" (click)="navigateToNcryptsec()">
|
||||
Get ncryptsec
|
||||
</button>
|
||||
}
|
||||
|
||||
<lib-toast #toast [bottom]="16"></lib-toast>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import {
|
||||
IconButtonComponent,
|
||||
NavComponent,
|
||||
@@ -29,6 +29,7 @@ export class KeysComponent extends NavComponent implements OnInit {
|
||||
|
||||
readonly #activatedRoute = inject(ActivatedRoute);
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
|
||||
ngOnInit(): void {
|
||||
const identityId = this.#activatedRoute.parent?.snapshot.params['id'];
|
||||
@@ -51,6 +52,11 @@ export class KeysComponent extends NavComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
navigateToNcryptsec() {
|
||||
if (!this.identity) return;
|
||||
this.#router.navigateByUrl(`/edit-identity/${this.identity.id}/ncryptsec`);
|
||||
}
|
||||
|
||||
async #initialize(identityId: string) {
|
||||
const identity = this.#storage
|
||||
.getBrowserSessionHandler()
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<div class="header-pane">
|
||||
<lib-icon-button
|
||||
icon="chevron-left"
|
||||
(click)="navigateBack()"
|
||||
></lib-icon-button>
|
||||
<span>Get ncryptsec</span>
|
||||
</div>
|
||||
|
||||
<!-- QR Code (shown after generation) -->
|
||||
@if (ncryptsec) {
|
||||
<div class="qr-container">
|
||||
<button
|
||||
type="button"
|
||||
class="qr-button"
|
||||
title="Copy to clipboard"
|
||||
(click)="copyToClipboard(ncryptsec); toast.show('Copied to clipboard')"
|
||||
>
|
||||
<img [src]="ncryptsecQr" alt="ncryptsec QR code" class="qr-code" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- PASSWORD INPUT -->
|
||||
<div class="password-section">
|
||||
<label for="ncryptsecPasswordInput">Password</label>
|
||||
<div class="input-group sam-mt-h">
|
||||
<input
|
||||
#passwordInput
|
||||
id="ncryptsecPasswordInput"
|
||||
type="password"
|
||||
class="form-control"
|
||||
placeholder="Enter encryption password"
|
||||
[(ngModel)]="ncryptsecPassword"
|
||||
[disabled]="isGenerating"
|
||||
(keyup.enter)="generateNcryptsec()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn-primary generate-btn"
|
||||
type="button"
|
||||
(click)="generateNcryptsec()"
|
||||
[disabled]="!ncryptsecPassword || isGenerating"
|
||||
>
|
||||
@if (isGenerating) {
|
||||
<span class="spinner-border spinner-border-sm" role="status"></span>
|
||||
Generating...
|
||||
} @else {
|
||||
Generate ncryptsec
|
||||
}
|
||||
</button>
|
||||
|
||||
|
||||
<p class="description">
|
||||
Enter a password to encrypt your private key. The resulting ncryptsec can be
|
||||
used to securely backup or transfer your key.
|
||||
</p>
|
||||
|
||||
<lib-toast #toast [bottom]="16"></lib-toast>
|
||||
@@ -0,0 +1,70 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
|
||||
.header-pane {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
column-gap: var(--size-h);
|
||||
align-items: center;
|
||||
padding-bottom: var(--size);
|
||||
background-color: var(--background);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: var(--size);
|
||||
}
|
||||
|
||||
.password-section {
|
||||
margin-bottom: var(--size);
|
||||
|
||||
label {
|
||||
font-weight: 500;
|
||||
margin-bottom: var(--size-q);
|
||||
}
|
||||
}
|
||||
|
||||
.generate-btn {
|
||||
width: 100%;
|
||||
margin-bottom: var(--size);
|
||||
}
|
||||
|
||||
.qr-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: var(--size);
|
||||
}
|
||||
|
||||
.qr-button {
|
||||
background: white;
|
||||
padding: var(--size);
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
ElementRef,
|
||||
inject,
|
||||
OnInit,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import {
|
||||
IconButtonComponent,
|
||||
NavComponent,
|
||||
NostrHelper,
|
||||
StorageService,
|
||||
ToastComponent,
|
||||
} from '@common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import * as QRCode from 'qrcode';
|
||||
|
||||
@Component({
|
||||
selector: 'app-ncryptsec',
|
||||
imports: [IconButtonComponent, FormsModule, ToastComponent],
|
||||
templateUrl: './ncryptsec.component.html',
|
||||
styleUrl: './ncryptsec.component.scss',
|
||||
})
|
||||
export class NcryptsecComponent
|
||||
extends NavComponent
|
||||
implements OnInit, AfterViewInit
|
||||
{
|
||||
@ViewChild('passwordInput') passwordInput!: ElementRef<HTMLInputElement>;
|
||||
|
||||
privkeyHex = '';
|
||||
ncryptsecPassword = '';
|
||||
ncryptsec = '';
|
||||
ncryptsecQr = '';
|
||||
isGenerating = false;
|
||||
|
||||
readonly #activatedRoute = inject(ActivatedRoute);
|
||||
readonly #storage = inject(StorageService);
|
||||
|
||||
ngOnInit(): void {
|
||||
const identityId = this.#activatedRoute.parent?.snapshot.params['id'];
|
||||
if (!identityId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#initialize(identityId);
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.passwordInput.nativeElement.focus();
|
||||
}
|
||||
|
||||
async generateNcryptsec() {
|
||||
if (!this.privkeyHex || !this.ncryptsecPassword) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isGenerating = true;
|
||||
this.ncryptsec = '';
|
||||
this.ncryptsecQr = '';
|
||||
|
||||
try {
|
||||
this.ncryptsec = await NostrHelper.privkeyToNcryptsec(
|
||||
this.privkeyHex,
|
||||
this.ncryptsecPassword
|
||||
);
|
||||
|
||||
// Generate QR code
|
||||
this.ncryptsecQr = await QRCode.toDataURL(this.ncryptsec, {
|
||||
width: 250,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to generate ncryptsec:', error);
|
||||
} finally {
|
||||
this.isGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
#initialize(identityId: string) {
|
||||
const identity = this.#storage
|
||||
.getBrowserSessionHandler()
|
||||
.browserSessionData?.identities.find((x) => x.id === identityId);
|
||||
|
||||
if (!identity) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.privkeyHex = identity.privkey;
|
||||
}
|
||||
}
|
||||
@@ -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 }}
|
||||
@@ -22,7 +27,7 @@
|
||||
>
|
||||
<span class="text-muted">{{ permission.method }}</span>
|
||||
@if(typeof permission.kind !== 'undefined') {
|
||||
<span>(kind {{ permission.kind }})</span>
|
||||
<span [title]="getKindTooltip(permission.kind!)">(kind {{ permission.kind }})</span>
|
||||
}
|
||||
<div class="sam-flex-grow"></div>
|
||||
<lib-icon-button
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.remove-all-btn {
|
||||
margin-bottom: var(--size);
|
||||
}
|
||||
|
||||
.permissions-card {
|
||||
background: var(--background-light);
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
NavComponent,
|
||||
Permission_DECRYPTED,
|
||||
StorageService,
|
||||
getKindName,
|
||||
} from '@common';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
@@ -41,6 +42,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()
|
||||
@@ -78,4 +87,8 @@ export class PermissionsComponent extends NavComponent implements OnInit {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getKindTooltip(kind: number): string {
|
||||
return getKindName(kind);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,86 @@
|
||||
<div class="sam-text-header">
|
||||
<div class="header-buttons">
|
||||
<button class="header-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
@if (devMode) {
|
||||
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
|
||||
<span class="emoji">✨</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<button class="back-btn" title="Go Back" (click)="goBack()">
|
||||
<span class="emoji">←</span>
|
||||
</button>
|
||||
<span>Backups</span>
|
||||
</div>
|
||||
|
||||
<div class="backup-settings">
|
||||
<div class="setting-row">
|
||||
<label for="maxBackups">Max Auto Backups:</label>
|
||||
<input
|
||||
id="maxBackups"
|
||||
type="number"
|
||||
[value]="maxBackups"
|
||||
min="1"
|
||||
max="20"
|
||||
(change)="onMaxBackupsChange($event)"
|
||||
/>
|
||||
</div>
|
||||
<p class="setting-note">
|
||||
Automatic backups are created when significant changes are made.
|
||||
Manual and pre-restore backups are not counted toward this limit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary create-btn" (click)="createManualBackup()">
|
||||
Create Backup Now
|
||||
</button>
|
||||
|
||||
<div class="backups-list">
|
||||
@if (backups.length === 0) {
|
||||
<div class="empty-state">
|
||||
<span>No backups yet</span>
|
||||
</div>
|
||||
}
|
||||
@for (backup of backups; track backup.id) {
|
||||
<div class="backup-item">
|
||||
<div class="backup-info">
|
||||
<span class="backup-date">{{ formatDate(backup.createdAt) }}</span>
|
||||
<div class="backup-meta">
|
||||
<span class="backup-reason" [class]="getReasonClass(backup.reason)">
|
||||
{{ getReasonLabel(backup.reason) }}
|
||||
</span>
|
||||
<span class="backup-identities">{{ backup.identityCount }} identity(ies)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="backup-actions">
|
||||
<button
|
||||
class="btn btn-sm btn-secondary"
|
||||
(click)="
|
||||
confirm.show(
|
||||
'Restore this backup? A backup of your current state will be created first.',
|
||||
restoreBackup.bind(this, backup.id)
|
||||
)
|
||||
"
|
||||
[disabled]="restoringBackupId !== null"
|
||||
>
|
||||
{{ restoringBackupId === backup.id ? 'Restoring...' : 'Restore' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-danger"
|
||||
(click)="
|
||||
confirm.show(
|
||||
'Delete this backup? This cannot be undone.',
|
||||
deleteBackup.bind(this, backup.id)
|
||||
)
|
||||
"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<lib-confirm #confirm></lib-confirm>
|
||||
@@ -0,0 +1,192 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sam-text-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.lock-btn,
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.backup-settings {
|
||||
background: var(--muted);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
width: 60px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.setting-note {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.backups-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.backup-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.backup-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.backup-date {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.backup-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.backup-reason {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
|
||||
&.reason-auto {
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
&.reason-manual {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: rgb(34, 197, 94);
|
||||
}
|
||||
|
||||
&.reason-prerestore {
|
||||
background: rgba(234, 179, 8, 0.2);
|
||||
color: rgb(234, 179, 8);
|
||||
}
|
||||
}
|
||||
|
||||
.backup-identities {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.backup-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--secondary);
|
||||
color: var(--secondary-foreground);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--muted);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: rgb(239, 68, 68);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
ConfirmComponent,
|
||||
LoggerService,
|
||||
NavComponent,
|
||||
SignerMetaData_VaultSnapshot,
|
||||
StartupService,
|
||||
} from '@common';
|
||||
import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage-service-config';
|
||||
|
||||
@Component({
|
||||
selector: 'app-backups',
|
||||
templateUrl: './backups.component.html',
|
||||
styleUrl: './backups.component.scss',
|
||||
imports: [ConfirmComponent],
|
||||
})
|
||||
export class BackupsComponent extends NavComponent implements OnInit {
|
||||
readonly #router = inject(Router);
|
||||
readonly #startup = inject(StartupService);
|
||||
readonly #logger = inject(LoggerService);
|
||||
|
||||
backups: SignerMetaData_VaultSnapshot[] = [];
|
||||
maxBackups = 5;
|
||||
restoringBackupId: string | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadBackups();
|
||||
this.maxBackups = this.storage.getSignerMetaHandler().getMaxBackups();
|
||||
}
|
||||
|
||||
loadBackups(): void {
|
||||
this.backups = this.storage.getSignerMetaHandler().getBackups();
|
||||
}
|
||||
|
||||
async onMaxBackupsChange(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const value = parseInt(input.value, 10);
|
||||
if (!isNaN(value) && value >= 1 && value <= 20) {
|
||||
this.maxBackups = value;
|
||||
await this.storage.getSignerMetaHandler().setMaxBackups(value);
|
||||
}
|
||||
}
|
||||
|
||||
async createManualBackup(): Promise<void> {
|
||||
const browserSyncData = this.storage.getBrowserSyncHandler().browserSyncData;
|
||||
if (browserSyncData) {
|
||||
await this.storage.getSignerMetaHandler().createBackup(browserSyncData, 'manual');
|
||||
this.loadBackups();
|
||||
}
|
||||
}
|
||||
|
||||
async restoreBackup(backupId: string): Promise<void> {
|
||||
this.restoringBackupId = backupId;
|
||||
try {
|
||||
// First, create a pre-restore backup of current state
|
||||
const currentData = this.storage.getBrowserSyncHandler().browserSyncData;
|
||||
if (currentData) {
|
||||
await this.storage.getSignerMetaHandler().createBackup(currentData, 'pre-restore');
|
||||
}
|
||||
|
||||
// Get the backup data
|
||||
const backupData = this.storage.getSignerMetaHandler().getBackupData(backupId);
|
||||
if (!backupData) {
|
||||
throw new Error('Backup not found');
|
||||
}
|
||||
|
||||
// Import the backup
|
||||
await this.storage.deleteVault(true);
|
||||
await this.storage.importVault(backupData);
|
||||
this.#logger.logVaultImport('Backup Restore');
|
||||
this.storage.isInitialized = false;
|
||||
this.#startup.startOver(getNewStorageServiceConfig());
|
||||
} catch (error) {
|
||||
console.error('Failed to restore backup:', error);
|
||||
this.restoringBackupId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteBackup(backupId: string): Promise<void> {
|
||||
await this.storage.getSignerMetaHandler().deleteBackup(backupId);
|
||||
this.loadBackups();
|
||||
}
|
||||
|
||||
formatDate(isoDate: string): string {
|
||||
const date = new Date(isoDate);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
||||
}
|
||||
|
||||
getReasonLabel(reason?: string): string {
|
||||
switch (reason) {
|
||||
case 'auto':
|
||||
return 'Auto';
|
||||
case 'manual':
|
||||
return 'Manual';
|
||||
case 'pre-restore':
|
||||
return 'Pre-Restore';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
getReasonClass(reason?: string): string {
|
||||
switch (reason) {
|
||||
case 'auto':
|
||||
return 'reason-auto';
|
||||
case 'manual':
|
||||
return 'reason-manual';
|
||||
case 'pre-restore':
|
||||
return 'reason-prerestore';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
this.#router.navigateByUrl('/home/settings');
|
||||
}
|
||||
|
||||
async onClickLock(): Promise<void> {
|
||||
this.#logger.logVaultLock();
|
||||
await this.storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
|
||||
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
|
||||
<div class="sam-text-header">
|
||||
<div class="header-buttons">
|
||||
<button class="header-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
@if (devMode) {
|
||||
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
|
||||
<span class="emoji">✨</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<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,98 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Bookmark, LoggerService, NavComponent, SignerMetaData } 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 extends NavComponent implements OnInit {
|
||||
readonly #logger = inject(LoggerService);
|
||||
readonly #metaHandler = new ChromeMetaHandler();
|
||||
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,44 @@
|
||||
<!-- 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">
|
||||
<div class="header-buttons">
|
||||
<button class="header-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
@if (devMode) {
|
||||
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
|
||||
<span class="emoji">✨</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<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 +65,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,113 @@
|
||||
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;
|
||||
.header-buttons {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-btn,
|
||||
.add-btn {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
position: absolute;
|
||||
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,8 @@ import { Router } from '@angular/router';
|
||||
import {
|
||||
IconButtonComponent,
|
||||
Identity_DECRYPTED,
|
||||
LoggerService,
|
||||
NavComponent,
|
||||
NostrHelper,
|
||||
ProfileMetadata,
|
||||
ProfileMetadataService,
|
||||
@@ -16,14 +18,19 @@ import {
|
||||
styleUrl: './identities.component.scss',
|
||||
imports: [IconButtonComponent, ToastComponent],
|
||||
})
|
||||
export class IdentitiesComponent implements OnInit {
|
||||
readonly storage = inject(StorageService);
|
||||
export class IdentitiesComponent extends NavComponent implements OnInit {
|
||||
override 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 +47,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 +67,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,20 @@
|
||||
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
|
||||
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
|
||||
<div class="sam-text-header">
|
||||
<div class="header-buttons">
|
||||
<button class="header-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
@if (devMode) {
|
||||
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
|
||||
<span class="emoji">✨</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<span>You</span>
|
||||
<button class="edit-btn" title="Edit profile" (click)="onClickEditProfile()">
|
||||
<span class="emoji">📝</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="identity-container">
|
||||
@@ -22,7 +36,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' }}
|
||||
@@ -67,4 +80,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- About section -->
|
||||
@if (aboutText) {
|
||||
<div class="about-section">
|
||||
<div class="about-header">About</div>
|
||||
<div class="about-content">{{ aboutText }}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<lib-toast #toast></lib-toast>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -161,4 +185,33 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.about-section {
|
||||
margin: var(--size);
|
||||
margin-top: 0;
|
||||
flex-shrink: 0;
|
||||
max-height: 150px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.about-header {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: var(--size-h);
|
||||
}
|
||||
|
||||
.about-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
color: var(--foreground);
|
||||
background: var(--background-light);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--size);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,16 @@ import { Component, inject, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
Identity_DECRYPTED,
|
||||
LoggerService,
|
||||
NavComponent,
|
||||
NostrHelper,
|
||||
ProfileMetadata,
|
||||
ProfileMetadataService,
|
||||
PubkeyComponent,
|
||||
StorageService,
|
||||
ToastComponent,
|
||||
VisualNip05Pipe,
|
||||
validateNip05,
|
||||
} from '@common';
|
||||
import NDK from '@nostr-dev-kit/ndk';
|
||||
|
||||
@Component({
|
||||
selector: 'app-identity',
|
||||
@@ -18,7 +19,7 @@ import NDK from '@nostr-dev-kit/ndk';
|
||||
templateUrl: './identity.component.html',
|
||||
styleUrl: './identity.component.scss',
|
||||
})
|
||||
export class IdentityComponent implements OnInit {
|
||||
export class IdentityComponent extends NavComponent implements OnInit {
|
||||
selectedIdentity: Identity_DECRYPTED | undefined;
|
||||
selectedIdentityNpub: string | undefined;
|
||||
profile: ProfileMetadata | null = null;
|
||||
@@ -26,9 +27,9 @@ export class IdentityComponent implements OnInit {
|
||||
validating = false;
|
||||
loading = true;
|
||||
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
readonly #profileMetadata = inject(ProfileMetadataService);
|
||||
readonly #logger = inject(LoggerService);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.#loadData();
|
||||
@@ -50,6 +51,10 @@ export class IdentityComponent implements OnInit {
|
||||
return this.profile?.banner;
|
||||
}
|
||||
|
||||
get aboutText(): string | undefined {
|
||||
return this.profile?.about;
|
||||
}
|
||||
|
||||
copyToClipboard(pubkey: string | undefined) {
|
||||
if (!pubkey) {
|
||||
return;
|
||||
@@ -67,13 +72,26 @@ 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 =
|
||||
this.#storage.getBrowserSessionHandler().browserSessionData
|
||||
this.storage.getBrowserSessionHandler().browserSessionData
|
||||
?.selectedIdentityId ?? null;
|
||||
|
||||
const identity = this.#storage
|
||||
const identity = this.storage
|
||||
.getBrowserSessionHandler()
|
||||
.browserSessionData?.identities.find(
|
||||
(x) => x.id === selectedIdentityId
|
||||
@@ -125,28 +143,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,14 @@
|
||||
<div class="sam-text-header">
|
||||
<div class="header-buttons">
|
||||
<button class="header-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
@if (devMode) {
|
||||
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
|
||||
<span class="emoji">✨</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<span> Plebeian Signer </span>
|
||||
</div>
|
||||
|
||||
@@ -8,9 +18,9 @@
|
||||
|
||||
<span> Source code</span>
|
||||
<a
|
||||
href="https://git.mleku.dev/mleku/plebeian-signer"
|
||||
href="https://github.com/PlebeianApp/plebeian-signer"
|
||||
target="_blank"
|
||||
>
|
||||
git.mleku.dev/mleku/plebeian-signer
|
||||
github.com/PlebeianApp/plebeian-signer
|
||||
</a>
|
||||
|
||||
|
||||
@@ -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, NavComponent } from '@common';
|
||||
import packageJson from '../../../../../../../package.json';
|
||||
|
||||
@Component({
|
||||
@@ -6,6 +8,15 @@ import packageJson from '../../../../../../../package.json';
|
||||
templateUrl: './info.component.html',
|
||||
styleUrl: './info.component.scss',
|
||||
})
|
||||
export class InfoComponent {
|
||||
export class InfoComponent extends NavComponent {
|
||||
readonly #logger = inject(LoggerService);
|
||||
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,30 @@
|
||||
<div class="sam-text-header">
|
||||
<div class="header-buttons">
|
||||
<button class="header-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
@if (devMode) {
|
||||
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
|
||||
<span class="emoji">✨</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<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,51 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { LoggerService, LogEntry, NavComponent } from '@common';
|
||||
import { DatePipe } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-logs',
|
||||
templateUrl: './logs.component.html',
|
||||
styleUrl: './logs.component.scss',
|
||||
imports: [DatePipe],
|
||||
})
|
||||
export class LogsComponent extends NavComponent implements OnInit {
|
||||
readonly #logger = inject(LoggerService);
|
||||
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,19 +1,47 @@
|
||||
<div class="sam-text-header">
|
||||
<div class="header-buttons">
|
||||
<button class="header-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
@if (devMode) {
|
||||
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
|
||||
<span class="emoji">✨</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<span> Settings </span>
|
||||
</div>
|
||||
|
||||
<span>SYNC: {{ syncFlow }}</span>
|
||||
<div class="vault-buttons">
|
||||
<button class="btn btn-primary" (click)="onClickExportVault()">
|
||||
Export Vault
|
||||
</button>
|
||||
<button class="btn btn-primary" (click)="navigate('/vault-import')">
|
||||
Import Vault
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" (click)="onClickExportVault()">
|
||||
Export Vault
|
||||
</button>
|
||||
<lib-nav-item text="💾 Backups" (click)="navigate('/home/backups')"></lib-nav-item>
|
||||
<lib-nav-item text="🪵 Logs" (click)="navigate('/home/logs')"></lib-nav-item>
|
||||
<lib-nav-item text="💡 Info" (click)="navigate('/home/info')"></lib-nav-item>
|
||||
|
||||
<button class="btn btn-primary" (click)="navigate('/vault-import')">
|
||||
Import Vault
|
||||
</button>
|
||||
<div class="dev-mode-row">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" [checked]="devMode" (change)="onToggleDevMode($event)" />
|
||||
<span>Dev Mode</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="sam-flex-grow"></div>
|
||||
|
||||
<div class="sync-info">
|
||||
<span class="sync-label">SYNC: {{ syncFlow }}</span>
|
||||
<p class="sync-note">
|
||||
To change sync mode, export your vault, reset the extension,
|
||||
and re-import with the desired sync setting.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
(click)="
|
||||
|
||||
@@ -4,11 +4,57 @@
|
||||
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;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.vault-buttons {
|
||||
display: flex;
|
||||
gap: var(--size);
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.dev-mode-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size);
|
||||
|
||||
.toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size-h);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sync-info {
|
||||
.sync-label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sync-note {
|
||||
margin: var(--size-h) 0 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted-foreground);
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
BrowserSyncData,
|
||||
BrowserSyncFlow,
|
||||
ConfirmComponent,
|
||||
DateHelper,
|
||||
LoggerService,
|
||||
NavComponent,
|
||||
NavItemComponent,
|
||||
StartupService,
|
||||
StorageService,
|
||||
} from '@common';
|
||||
import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage-service-config';
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
@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;
|
||||
override devMode = false;
|
||||
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #startup = inject(StartupService);
|
||||
readonly #logger = inject(LoggerService);
|
||||
|
||||
ngOnInit(): void {
|
||||
const vault = JSON.stringify(
|
||||
@@ -40,10 +47,49 @@ export class SettingsComponent extends NavComponent implements OnInit {
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Load dev mode setting
|
||||
this.devMode = this.#storage.getSignerMetaHandler().signerMetaData?.devMode ?? false;
|
||||
}
|
||||
|
||||
async onToggleDevMode(event: Event) {
|
||||
const checked = (event.target as HTMLInputElement).checked;
|
||||
this.devMode = checked;
|
||||
await this.#storage.getSignerMetaHandler().setDevMode(checked);
|
||||
}
|
||||
|
||||
override async onTestPrompt() {
|
||||
// Open a test permission prompt window
|
||||
const testEvent = {
|
||||
kind: 1,
|
||||
content: 'This is a test note for permission prompt preview.',
|
||||
tags: [],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
const base64Event = Buffer.from(JSON.stringify(testEvent, null, 2)).toString('base64');
|
||||
const currentIdentity = this.#storage.getBrowserSessionHandler().browserSessionData?.identities.find(
|
||||
i => i.id === this.#storage.getBrowserSessionHandler().browserSessionData?.selectedIdentityId
|
||||
);
|
||||
const nick = currentIdentity?.nick ?? 'Test Identity';
|
||||
|
||||
const width = 375;
|
||||
const height = 600;
|
||||
const left = Math.round((screen.width - width) / 2);
|
||||
const top = Math.round((screen.height - height) / 2);
|
||||
|
||||
chrome.windows.create({
|
||||
type: 'popup',
|
||||
url: `prompt.html?method=signEvent&host=example.com&id=test-${Date.now()}&nick=${encodeURIComponent(nick)}&event=${base64Event}`,
|
||||
width,
|
||||
height,
|
||||
left,
|
||||
top,
|
||||
});
|
||||
}
|
||||
|
||||
async onResetExtension() {
|
||||
try {
|
||||
this.#logger.logVaultReset();
|
||||
await this.#storage.resetExtension();
|
||||
this.#startup.startOver(getNewStorageServiceConfig());
|
||||
} catch (error) {
|
||||
@@ -69,6 +115,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 +131,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 +144,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,680 @@
|
||||
<div class="sam-text-header">
|
||||
<div class="header-buttons">
|
||||
<button class="header-btn" title="Lock" (click)="onClickLock()">
|
||||
<span class="emoji">🔒</span>
|
||||
</button>
|
||||
@if (devMode) {
|
||||
<button class="header-btn" title="Test Permission Prompt" (click)="onTestPrompt()">
|
||||
<span class="emoji">✨</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (showBackButton) {
|
||||
<button class="back-btn" title="Go Back" (click)="goBack()">
|
||||
<span class="emoji">←</span>
|
||||
</button>
|
||||
}
|
||||
<span>{{ title }}</span>
|
||||
<div class="section-btns">
|
||||
<button
|
||||
class="section-btn"
|
||||
[class.active]="activeSection.startsWith('cashu')"
|
||||
title="Cashu"
|
||||
(click)="setSection('cashu')"
|
||||
>
|
||||
<span class="emoji">🥜</span>
|
||||
</button>
|
||||
<button
|
||||
class="section-btn"
|
||||
[class.active]="activeSection.startsWith('lightning')"
|
||||
title="Lightning"
|
||||
(click)="setSection('lightning')"
|
||||
>
|
||||
<span class="emoji">⚡</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wallet-container">
|
||||
<!-- Main wallet menu -->
|
||||
@if (activeSection === 'main') {
|
||||
<div class="wallet-menu">
|
||||
<button class="wallet-menu-item" (click)="setSection('cashu')">
|
||||
<span class="emoji">🥜</span>
|
||||
<span class="label">Cashu</span>
|
||||
<span class="balance">{{ formatCashuBalance(totalCashuBalance) }} sats</span>
|
||||
</button>
|
||||
<button class="wallet-menu-item" (click)="setSection('lightning')">
|
||||
<span class="emoji">⚡</span>
|
||||
<span class="label">Lightning</span>
|
||||
<span class="balance">{{ formatBalance(totalLightningBalance) }} sats</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Cashu mint list -->
|
||||
@else if (activeSection === 'cashu') {
|
||||
<div class="lightning-section">
|
||||
@if (mints.length === 0) {
|
||||
<div class="cashu-onboarding">
|
||||
<div class="empty-state">
|
||||
<span class="sam-text-muted">No mints connected yet.</span>
|
||||
|
||||
<!-- Suggested mints for quick-add -->
|
||||
<div class="quick-add-section">
|
||||
<div class="quick-add-label">Quick Add a Mint</div>
|
||||
<div class="quick-add-menu">
|
||||
@for (mint of suggestedMints; track mint.url) {
|
||||
@if (!isMintAlreadyAdded(mint.url)) {
|
||||
<button
|
||||
class="quick-add-item"
|
||||
[disabled]="addingMint"
|
||||
(click)="quickAddMint(mint)"
|
||||
>
|
||||
<span class="mint-row">
|
||||
<span class="add-icon">+</span>
|
||||
<span class="mint-name">{{ mint.name }}</span>
|
||||
</span>
|
||||
<span class="mint-desc">{{ mint.description }}</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (mintError) {
|
||||
<div class="error-message small">{{ mintError }}</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="backup-reminder">
|
||||
<span>Have you set up backups?</span>
|
||||
<button class="link-btn" (click)="navigateToSettings()">
|
||||
Configure Backups
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="wallet-list">
|
||||
@for (mint of mints; track mint.id) {
|
||||
<button class="wallet-list-item" (click)="selectMint(mint.id)">
|
||||
<span class="wallet-name">{{ mint.name }}</span>
|
||||
<span class="wallet-balance">{{ formatCashuBalance(mint.cachedBalance) }} sats</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Quick add disclosure when mints exist -->
|
||||
@if (hasUnavailableMints()) {
|
||||
<details class="quick-add-disclosure">
|
||||
<summary>Quick Add</summary>
|
||||
<div class="quick-add-menu">
|
||||
@for (mint of suggestedMints; track mint.url) {
|
||||
@if (!isMintAlreadyAdded(mint.url)) {
|
||||
<button
|
||||
class="quick-add-item"
|
||||
[disabled]="addingMint"
|
||||
(click)="quickAddMint(mint)"
|
||||
>
|
||||
<span class="mint-row">
|
||||
<span class="add-icon">+</span>
|
||||
<span class="mint-name">{{ mint.name }}</span>
|
||||
</span>
|
||||
<span class="mint-desc">{{ mint.description }}</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (mintError) {
|
||||
<div class="error-message small">{{ mintError }}</div>
|
||||
}
|
||||
</details>
|
||||
}
|
||||
}
|
||||
<button class="add-wallet-btn" (click)="showAddMint()">
|
||||
<span class="emoji">+</span>
|
||||
<span>Add Mint</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Cashu mint detail -->
|
||||
@else if (activeSection === 'cashu-detail' && selectedMint) {
|
||||
<div class="wallet-detail">
|
||||
<div class="balance-row">
|
||||
<div class="balance-display compact">
|
||||
<span class="balance-value">{{ formatCashuBalance(selectedMintBalance) }}</span>
|
||||
<span class="balance-unit">sats</span>
|
||||
</div>
|
||||
<button
|
||||
class="refresh-icon-btn"
|
||||
(click)="refreshMint()"
|
||||
[disabled]="refreshingMint"
|
||||
title="Refresh"
|
||||
>
|
||||
<span class="emoji" [class.spinning]="refreshingMint">🔄</span>
|
||||
</button>
|
||||
</div>
|
||||
@if (refreshError) {
|
||||
<div class="error-message small">{{ refreshError }}</div>
|
||||
}
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn deposit-btn" (click)="showDeposit()">
|
||||
Deposit
|
||||
</button>
|
||||
<button class="action-btn receive-btn" (click)="showReceive()">
|
||||
Receive
|
||||
</button>
|
||||
<button class="action-btn send-btn" (click)="showSend()" [disabled]="selectedMintBalance === 0">
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Token viewer section -->
|
||||
<div class="token-section">
|
||||
<div class="section-title">Tokens ({{ selectedMintProofs.length }})</div>
|
||||
@if (selectedMintProofs.length === 0) {
|
||||
<div class="empty-text">No tokens stored</div>
|
||||
} @else {
|
||||
<div class="token-list">
|
||||
@for (proof of selectedMintProofs; track proof.secret) {
|
||||
<div class="token-item">
|
||||
<span class="token-amount">{{ proof.amount }}</span>
|
||||
<span class="token-time">{{ formatProofTime(proof.receivedAt) }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="wallet-info">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Mint URL</span>
|
||||
<span class="info-value">{{ selectedMint.mintUrl }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Unit</span>
|
||||
<span class="info-value">{{ selectedMint.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="delete-btn" (click)="deleteMint()">
|
||||
Delete Mint
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Cashu add mint form -->
|
||||
@else if (activeSection === 'cashu-add') {
|
||||
<div class="add-wallet-form">
|
||||
<!-- Suggested mints -->
|
||||
<div class="suggested-mints">
|
||||
<div class="suggested-label">Quick Add</div>
|
||||
<div class="suggested-list">
|
||||
@for (mint of suggestedMints; track mint.url) {
|
||||
<button
|
||||
class="suggested-mint-btn"
|
||||
[class.already-added]="isMintAlreadyAdded(mint.url)"
|
||||
[disabled]="isMintAlreadyAdded(mint.url) || addingMint"
|
||||
(click)="selectSuggestedMint(mint)"
|
||||
[title]="mint.description"
|
||||
>
|
||||
<span class="mint-name">{{ mint.name }}</span>
|
||||
@if (isMintAlreadyAdded(mint.url)) {
|
||||
<span class="added-badge">✓</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-divider">
|
||||
<span>or enter manually</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="mintName">Mint Name</label>
|
||||
<input
|
||||
id="mintName"
|
||||
type="text"
|
||||
[(ngModel)]="newMintName"
|
||||
placeholder="My Mint"
|
||||
[disabled]="addingMint"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="mintUrl">Mint URL</label>
|
||||
<input
|
||||
id="mintUrl"
|
||||
type="text"
|
||||
[(ngModel)]="newMintUrl"
|
||||
placeholder="https://mint.example.com"
|
||||
[disabled]="addingMint"
|
||||
/>
|
||||
</div>
|
||||
@if (mintError) {
|
||||
<div class="error-message">{{ mintError }}</div>
|
||||
}
|
||||
@if (mintTestResult) {
|
||||
<div class="success-message">{{ mintTestResult }}</div>
|
||||
}
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="test-btn"
|
||||
(click)="testMint()"
|
||||
[disabled]="testingMint || addingMint"
|
||||
>
|
||||
{{ testingMint ? 'Testing...' : 'Test Connection' }}
|
||||
</button>
|
||||
<button
|
||||
class="add-btn"
|
||||
(click)="addMint()"
|
||||
[disabled]="addingMint"
|
||||
>
|
||||
{{ addingMint ? 'Adding...' : 'Add Mint' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Cashu receive token -->
|
||||
@else if (activeSection === 'cashu-receive') {
|
||||
<div class="add-wallet-form">
|
||||
<div class="form-group">
|
||||
<label for="receiveToken">Paste Cashu Token</label>
|
||||
<textarea
|
||||
id="receiveToken"
|
||||
[(ngModel)]="receiveToken"
|
||||
placeholder="cashuB..."
|
||||
rows="5"
|
||||
[disabled]="receivingToken"
|
||||
></textarea>
|
||||
</div>
|
||||
@if (receiveError) {
|
||||
<div class="error-message">{{ receiveError }}</div>
|
||||
}
|
||||
@if (receiveResult) {
|
||||
<div class="success-message">{{ receiveResult }}</div>
|
||||
}
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="add-btn full-width"
|
||||
(click)="receiveTokens()"
|
||||
[disabled]="receivingToken"
|
||||
>
|
||||
{{ receivingToken ? 'Receiving...' : 'Receive Tokens' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Cashu send token -->
|
||||
@else if (activeSection === 'cashu-send') {
|
||||
<div class="add-wallet-form">
|
||||
<div class="balance-info">
|
||||
Available: {{ formatCashuBalance(selectedMintBalance) }} sats
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sendAmount">Amount (sats)</label>
|
||||
<input
|
||||
id="sendAmount"
|
||||
type="number"
|
||||
[(ngModel)]="sendAmount"
|
||||
placeholder="0"
|
||||
min="1"
|
||||
[max]="selectedMintBalance"
|
||||
[disabled]="sendingToken"
|
||||
/>
|
||||
</div>
|
||||
@if (sendError) {
|
||||
<div class="error-message">{{ sendError }}</div>
|
||||
}
|
||||
@if (sendResult) {
|
||||
<div class="token-result">
|
||||
<span class="token-label">Token to Share</span>
|
||||
<textarea readonly rows="4">{{ sendResult }}</textarea>
|
||||
<button class="copy-btn" (click)="copyToken()">
|
||||
Copy Token
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (!sendResult) {
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="add-btn full-width"
|
||||
(click)="sendTokens()"
|
||||
[disabled]="sendingToken || sendAmount <= 0"
|
||||
>
|
||||
{{ sendingToken ? 'Creating...' : 'Create Token' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Cashu deposit (mint via Lightning) -->
|
||||
@else if (activeSection === 'cashu-mint' && selectedMint) {
|
||||
<div class="add-wallet-form">
|
||||
@if (!depositInvoice) {
|
||||
<div class="form-group">
|
||||
<label for="depositAmount">Amount (sats)</label>
|
||||
<input
|
||||
id="depositAmount"
|
||||
type="number"
|
||||
[(ngModel)]="depositAmount"
|
||||
placeholder="1000"
|
||||
min="1"
|
||||
[disabled]="creatingDepositQuote"
|
||||
/>
|
||||
</div>
|
||||
@if (depositError) {
|
||||
<div class="error-message">{{ depositError }}</div>
|
||||
}
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="add-btn full-width"
|
||||
(click)="createDepositInvoice()"
|
||||
[disabled]="creatingDepositQuote || depositAmount <= 0"
|
||||
>
|
||||
{{ creatingDepositQuote ? 'Creating...' : 'Create Invoice' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (depositInvoice) {
|
||||
<div class="invoice-result">
|
||||
@if (depositInvoiceQr) {
|
||||
<img [src]="depositInvoiceQr" alt="Invoice QR Code" class="qr-code" />
|
||||
}
|
||||
<div class="deposit-status">
|
||||
@if (depositQuoteState === 'UNPAID') {
|
||||
<span class="status-waiting">Waiting for payment...</span>
|
||||
@if (checkingDepositPayment) {
|
||||
<span class="status-checking">checking</span>
|
||||
}
|
||||
} @else if (depositQuoteState === 'PAID') {
|
||||
<span class="status-paid">Payment received! Claiming tokens...</span>
|
||||
} @else if (depositQuoteState === 'ISSUED') {
|
||||
<span class="status-issued">✓ Tokens received!</span>
|
||||
}
|
||||
</div>
|
||||
@if (depositError) {
|
||||
<div class="error-message">{{ depositError }}</div>
|
||||
}
|
||||
@if (depositSuccess) {
|
||||
<div class="success-message">{{ depositSuccess }}</div>
|
||||
}
|
||||
@if (depositQuoteState === 'UNPAID') {
|
||||
<div class="invoice-text">{{ depositInvoice }}</div>
|
||||
<button class="copy-btn" (click)="copyDepositInvoice()">
|
||||
Copy Invoice
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Lightning wallet list -->
|
||||
@else if (activeSection === 'lightning') {
|
||||
<div class="lightning-section">
|
||||
@if (connections.length === 0) {
|
||||
<div class="empty-state">
|
||||
<span class="sam-text-muted">
|
||||
No wallets connected yet.
|
||||
</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="wallet-list">
|
||||
@for (conn of connections; track conn.id) {
|
||||
<button class="wallet-list-item" (click)="selectConnection(conn.id)">
|
||||
<span class="wallet-name">{{ conn.name }}</span>
|
||||
<span class="wallet-balance">{{ formatBalance(conn.cachedBalance) }} sats</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<button class="add-wallet-btn" (click)="showAddConnection()">
|
||||
<span class="emoji">+</span>
|
||||
<span>Add NWC Connection</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Lightning wallet detail -->
|
||||
@else if (activeSection === 'lightning-detail' && selectedConnection) {
|
||||
<div class="wallet-detail">
|
||||
<div class="balance-row">
|
||||
<div class="balance-display compact">
|
||||
<span class="balance-value">{{ formatBalance(selectedConnection.cachedBalance) }}</span>
|
||||
<span class="balance-unit">sats</span>
|
||||
</div>
|
||||
<button class="refresh-icon-btn" (click)="refreshWallet()" title="Refresh">
|
||||
<span class="emoji">🔄</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn receive-btn" (click)="showLnReceive()">
|
||||
Receive
|
||||
</button>
|
||||
<button class="action-btn send-btn" (click)="showLnPay()">
|
||||
Pay
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="wallet-info">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Relay</span>
|
||||
<span class="info-value">{{ selectedConnection.relayUrl }}</span>
|
||||
</div>
|
||||
@if (selectedConnection.lud16) {
|
||||
<button class="info-row-btn" (click)="copyLightningAddress()">
|
||||
<span class="info-label">Lightning Address</span>
|
||||
<span class="info-value">
|
||||
{{ selectedConnection.lud16 }}
|
||||
<span class="copy-hint">{{ addressCopied ? '✓ Copied' : '(tap to copy)' }}</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Transaction History -->
|
||||
<div class="transaction-section">
|
||||
<div class="section-title">Transactions</div>
|
||||
@if (loadingTransactions) {
|
||||
<div class="loading-text">Loading...</div>
|
||||
} @else if (transactionsNotSupported) {
|
||||
<div class="not-supported-text">Transaction history not supported by this wallet</div>
|
||||
} @else if (transactionsError) {
|
||||
<div class="error-text">{{ transactionsError }}</div>
|
||||
} @else if (transactions.length === 0) {
|
||||
<div class="empty-text">No transactions yet</div>
|
||||
} @else {
|
||||
<div class="transaction-list">
|
||||
@for (tx of transactions; track tx.payment_hash) {
|
||||
<div class="transaction-item" [class.incoming]="tx.type === 'incoming'" [class.outgoing]="tx.type === 'outgoing'">
|
||||
<span class="tx-icon">{{ tx.type === 'incoming' ? '⬇' : '⬆' }}</span>
|
||||
<span class="tx-type">{{ tx.type === 'incoming' ? 'Received' : 'Sent' }}</span>
|
||||
<span class="tx-amount">{{ formatBalance(tx.amount) }}</span>
|
||||
<span class="tx-time">{{ formatTransactionTime(tx.created_at) }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button class="delete-btn-small" (click)="deleteConnection()">
|
||||
Delete Wallet
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Lightning receive invoice -->
|
||||
@else if (activeSection === 'lightning-receive' && selectedConnection) {
|
||||
<div class="add-wallet-form">
|
||||
<div class="form-group">
|
||||
<label for="lnReceiveAmount">Amount (sats)</label>
|
||||
<input
|
||||
id="lnReceiveAmount"
|
||||
type="number"
|
||||
[(ngModel)]="lnReceiveAmount"
|
||||
placeholder="1000"
|
||||
min="1"
|
||||
[disabled]="generatingInvoice"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="lnReceiveDescription">Description (optional)</label>
|
||||
<input
|
||||
id="lnReceiveDescription"
|
||||
type="text"
|
||||
[(ngModel)]="lnReceiveDescription"
|
||||
placeholder="Payment for..."
|
||||
[disabled]="generatingInvoice"
|
||||
/>
|
||||
</div>
|
||||
@if (lnReceiveError) {
|
||||
<div class="error-message">{{ lnReceiveError }}</div>
|
||||
}
|
||||
@if (!generatedInvoice) {
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="add-btn full-width"
|
||||
(click)="createReceiveInvoice()"
|
||||
[disabled]="generatingInvoice || lnReceiveAmount <= 0"
|
||||
>
|
||||
{{ generatingInvoice ? 'Generating...' : 'Generate Invoice' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (generatedInvoice) {
|
||||
<div class="invoice-result">
|
||||
@if (generatedInvoiceQr) {
|
||||
<img [src]="generatedInvoiceQr" alt="Invoice QR Code" class="qr-code" />
|
||||
}
|
||||
<div class="invoice-text">{{ generatedInvoice }}</div>
|
||||
<button class="copy-btn" (click)="copyInvoice()">
|
||||
{{ invoiceCopied ? 'Copied!' : 'Copy Invoice' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Pay Modal Overlay -->
|
||||
@if (showPayModal && selectedConnection) {
|
||||
<div class="modal-overlay" role="dialog" aria-modal="true" tabindex="-1" (click)="closePayModal()" (keydown.escape)="closePayModal()">
|
||||
<div class="modal-content" role="document" (click)="$event.stopPropagation()" (keydown)="$event.stopPropagation()">
|
||||
<div class="modal-header">
|
||||
<span>Pay Invoice</span>
|
||||
<button class="modal-close" (click)="closePayModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="payInput">Lightning Address or Invoice</label>
|
||||
<textarea
|
||||
id="payInput"
|
||||
[(ngModel)]="payInput"
|
||||
placeholder="user@domain.com or lnbc1..."
|
||||
rows="3"
|
||||
[disabled]="paying"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="payAmount">Amount (sats) - required for addresses</label>
|
||||
<input
|
||||
id="payAmount"
|
||||
type="number"
|
||||
[(ngModel)]="payAmount"
|
||||
placeholder="Optional for invoices"
|
||||
min="1"
|
||||
[disabled]="paying"
|
||||
/>
|
||||
</div>
|
||||
@if (paymentError) {
|
||||
<div class="error-message">{{ paymentError }}</div>
|
||||
}
|
||||
@if (paymentSuccess) {
|
||||
<div class="success-message payment-success">Payment Successful!</div>
|
||||
}
|
||||
@if (!paymentSuccess) {
|
||||
<div class="form-actions">
|
||||
<button class="test-btn" (click)="closePayModal()" [disabled]="paying">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="add-btn"
|
||||
(click)="payInvoiceOrAddress()"
|
||||
[disabled]="paying || !payInput.trim()"
|
||||
>
|
||||
{{ paying ? 'Paying...' : 'Pay' }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Add wallet form -->
|
||||
@else if (activeSection === 'lightning-add') {
|
||||
<div class="add-wallet-form">
|
||||
<div class="form-group">
|
||||
<label for="walletName">Wallet Name</label>
|
||||
<input
|
||||
id="walletName"
|
||||
type="text"
|
||||
[(ngModel)]="newWalletName"
|
||||
placeholder="My Lightning Wallet"
|
||||
[disabled]="addingConnection"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="walletUrl">NWC Connection URL</label>
|
||||
<textarea
|
||||
id="walletUrl"
|
||||
[(ngModel)]="newWalletUrl"
|
||||
placeholder="nostr+walletconnect://..."
|
||||
rows="3"
|
||||
[disabled]="addingConnection"
|
||||
></textarea>
|
||||
</div>
|
||||
@if (connectionError) {
|
||||
<div class="error-message">{{ connectionError }}</div>
|
||||
}
|
||||
@if (connectionTestResult) {
|
||||
<div class="success-message">{{ connectionTestResult }}</div>
|
||||
}
|
||||
@if (nwcService.logs.length > 0) {
|
||||
<div class="nwc-log">
|
||||
<div class="log-header">
|
||||
<span>Connection Log</span>
|
||||
<button class="log-clear-btn" (click)="nwcService.clearLogs()">Clear</button>
|
||||
</div>
|
||||
<div class="log-entries">
|
||||
@for (entry of nwcService.logs; track entry.timestamp) {
|
||||
<div class="log-entry" [class.log-warn]="entry.level === 'warn'" [class.log-error]="entry.level === 'error'">
|
||||
<span class="log-time">{{ entry.timestamp | date:'HH:mm:ss' }}</span>
|
||||
<span class="log-message">{{ entry.message }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="test-btn"
|
||||
(click)="testConnection()"
|
||||
[disabled]="testingConnection || addingConnection"
|
||||
>
|
||||
{{ testingConnection ? 'Testing...' : 'Test Connection' }}
|
||||
</button>
|
||||
<button
|
||||
class="add-btn"
|
||||
(click)="addConnection()"
|
||||
[disabled]="addingConnection"
|
||||
>
|
||||
{{ addingConnection ? 'Adding...' : 'Add Wallet' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
1331
projects/chrome/src/app/components/home/wallet/wallet.component.scss
Normal file
@@ -0,0 +1,989 @@
|
||||
import { Component, inject, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
LoggerService,
|
||||
NavComponent,
|
||||
NwcService,
|
||||
NwcConnection_DECRYPTED,
|
||||
CashuService,
|
||||
CashuMint_DECRYPTED,
|
||||
CashuProof,
|
||||
NwcLookupInvoiceResult,
|
||||
BrowserSyncFlow,
|
||||
} from '@common';
|
||||
import * as QRCode from 'qrcode';
|
||||
|
||||
type WalletSection =
|
||||
| 'main'
|
||||
| 'cashu'
|
||||
| 'cashu-detail'
|
||||
| 'cashu-add'
|
||||
| 'cashu-receive'
|
||||
| 'cashu-send'
|
||||
| 'cashu-mint'
|
||||
| 'lightning'
|
||||
| 'lightning-detail'
|
||||
| 'lightning-add'
|
||||
| 'lightning-receive'
|
||||
| 'lightning-pay';
|
||||
|
||||
@Component({
|
||||
selector: 'app-wallet',
|
||||
templateUrl: './wallet.component.html',
|
||||
styleUrl: './wallet.component.scss',
|
||||
imports: [CommonModule, FormsModule],
|
||||
})
|
||||
export class WalletComponent extends NavComponent implements OnInit, OnDestroy {
|
||||
readonly #logger = inject(LoggerService);
|
||||
readonly #router = inject(Router);
|
||||
readonly nwcService = inject(NwcService);
|
||||
readonly cashuService = inject(CashuService);
|
||||
|
||||
activeSection: WalletSection = 'main';
|
||||
selectedConnectionId: string | null = null;
|
||||
selectedMintId: string | null = null;
|
||||
|
||||
// Form fields for adding new NWC connection
|
||||
newWalletName = '';
|
||||
newWalletUrl = '';
|
||||
addingConnection = false;
|
||||
testingConnection = false;
|
||||
connectionError = '';
|
||||
connectionTestResult = '';
|
||||
|
||||
// Form fields for adding new Cashu mint
|
||||
newMintName = '';
|
||||
newMintUrl = '';
|
||||
addingMint = false;
|
||||
testingMint = false;
|
||||
mintError = '';
|
||||
mintTestResult = '';
|
||||
|
||||
// Cashu receive/send fields
|
||||
receiveToken = '';
|
||||
receivingToken = false;
|
||||
receiveError = '';
|
||||
receiveResult = '';
|
||||
sendAmount = 0;
|
||||
sendingToken = false;
|
||||
sendError = '';
|
||||
sendResult = '';
|
||||
|
||||
// Cashu mint (deposit) fields
|
||||
depositAmount = 0;
|
||||
creatingDepositQuote = false;
|
||||
depositQuoteId = '';
|
||||
depositInvoice = '';
|
||||
depositInvoiceQr = '';
|
||||
depositError = '';
|
||||
depositSuccess = '';
|
||||
checkingDepositPayment = false;
|
||||
depositQuoteState: 'UNPAID' | 'PAID' | 'ISSUED' = 'UNPAID';
|
||||
private depositPollingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Loading states
|
||||
loadingBalances = false;
|
||||
balanceError = '';
|
||||
|
||||
// Lightning transaction history
|
||||
transactions: NwcLookupInvoiceResult[] = [];
|
||||
loadingTransactions = false;
|
||||
transactionsError = '';
|
||||
transactionsNotSupported = false;
|
||||
|
||||
// Lightning receive
|
||||
lnReceiveAmount = 0;
|
||||
lnReceiveDescription = '';
|
||||
generatingInvoice = false;
|
||||
generatedInvoice = '';
|
||||
generatedInvoiceQr = '';
|
||||
lnReceiveError = '';
|
||||
invoiceCopied = false;
|
||||
|
||||
// Lightning pay
|
||||
showPayModal = false;
|
||||
payInput = '';
|
||||
payAmount = 0;
|
||||
paying = false;
|
||||
paymentSuccess = false;
|
||||
paymentError = '';
|
||||
|
||||
// Clipboard feedback
|
||||
addressCopied = false;
|
||||
|
||||
// Cashu onboarding info
|
||||
showCashuInfo = true;
|
||||
currentSyncFlow: BrowserSyncFlow = BrowserSyncFlow.NO_SYNC;
|
||||
readonly BrowserSyncFlow = BrowserSyncFlow; // Expose enum to template
|
||||
readonly browserDownloadSettingsUrl = 'chrome://settings/downloads';
|
||||
|
||||
// Cashu mint refresh
|
||||
refreshingMint = false;
|
||||
refreshError = '';
|
||||
|
||||
// Suggested mints for quick-add
|
||||
readonly suggestedMints = [
|
||||
{ name: 'Minibits', url: 'https://mint.minibits.cash', description: 'Well-established mobile wallet mint' },
|
||||
{ name: 'Coinos', url: 'https://mint.coinos.io', description: 'Lightning wallet with Cashu integration' },
|
||||
{ name: '21Mint', url: 'https://21mint.me', description: 'Community mint' },
|
||||
{ name: 'Macadamia', url: 'https://mint.macadamia.cash', description: 'Reliable community mint' },
|
||||
{ name: 'Stablenut (USD)', url: 'https://stablenut.umint.cash', unit: 'usd', description: 'USD-denominated mint' },
|
||||
];
|
||||
|
||||
get title(): string {
|
||||
switch (this.activeSection) {
|
||||
case 'cashu':
|
||||
return 'Cashu';
|
||||
case 'cashu-detail':
|
||||
return this.selectedMint?.name ?? 'Mint';
|
||||
case 'cashu-add':
|
||||
return 'Add Mint';
|
||||
case 'cashu-receive':
|
||||
return 'Receive';
|
||||
case 'cashu-send':
|
||||
return 'Send';
|
||||
case 'cashu-mint':
|
||||
return 'Deposit';
|
||||
case 'lightning':
|
||||
return 'Lightning';
|
||||
case 'lightning-detail':
|
||||
return this.selectedConnection?.name ?? 'Wallet';
|
||||
case 'lightning-add':
|
||||
return 'Add Wallet';
|
||||
case 'lightning-receive':
|
||||
return 'Receive';
|
||||
case 'lightning-pay':
|
||||
return 'Pay';
|
||||
default:
|
||||
return 'Wallet';
|
||||
}
|
||||
}
|
||||
|
||||
get showBackButton(): boolean {
|
||||
return this.activeSection !== 'main';
|
||||
}
|
||||
|
||||
get connections(): NwcConnection_DECRYPTED[] {
|
||||
return this.nwcService.getConnections();
|
||||
}
|
||||
|
||||
get selectedConnection(): NwcConnection_DECRYPTED | undefined {
|
||||
if (!this.selectedConnectionId) return undefined;
|
||||
return this.nwcService.getConnection(this.selectedConnectionId);
|
||||
}
|
||||
|
||||
get totalLightningBalance(): number {
|
||||
return this.nwcService.getCachedTotalBalance();
|
||||
}
|
||||
|
||||
get mints(): CashuMint_DECRYPTED[] {
|
||||
return this.cashuService.getMints();
|
||||
}
|
||||
|
||||
get selectedMint(): CashuMint_DECRYPTED | undefined {
|
||||
if (!this.selectedMintId) return undefined;
|
||||
return this.cashuService.getMint(this.selectedMintId);
|
||||
}
|
||||
|
||||
get totalCashuBalance(): number {
|
||||
return this.cashuService.getCachedTotalBalance();
|
||||
}
|
||||
|
||||
get selectedMintBalance(): number {
|
||||
if (!this.selectedMintId) return 0;
|
||||
return this.cashuService.getBalance(this.selectedMintId);
|
||||
}
|
||||
|
||||
get selectedMintProofs(): CashuProof[] {
|
||||
if (!this.selectedMintId) return [];
|
||||
return this.cashuService.getProofs(this.selectedMintId);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Load current sync flow setting
|
||||
this.currentSyncFlow = this.storage.getSyncFlow();
|
||||
|
||||
// Refresh balances on init if we have connections
|
||||
if (this.connections.length > 0) {
|
||||
this.refreshAllBalances();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.nwcService.disconnectAll();
|
||||
this.stopDepositPolling();
|
||||
}
|
||||
|
||||
setSection(section: WalletSection) {
|
||||
this.activeSection = section;
|
||||
this.connectionError = '';
|
||||
this.connectionTestResult = '';
|
||||
}
|
||||
|
||||
goBack() {
|
||||
switch (this.activeSection) {
|
||||
case 'lightning-detail':
|
||||
case 'lightning-add':
|
||||
this.activeSection = 'lightning';
|
||||
this.selectedConnectionId = null;
|
||||
this.resetAddForm();
|
||||
this.resetLightningForms();
|
||||
break;
|
||||
case 'lightning-receive':
|
||||
case 'lightning-pay':
|
||||
this.activeSection = 'lightning-detail';
|
||||
this.resetLightningForms();
|
||||
break;
|
||||
case 'cashu-detail':
|
||||
case 'cashu-add':
|
||||
this.activeSection = 'cashu';
|
||||
this.selectedMintId = null;
|
||||
this.resetAddMintForm();
|
||||
break;
|
||||
case 'cashu-receive':
|
||||
case 'cashu-send':
|
||||
case 'cashu-mint':
|
||||
this.activeSection = 'cashu-detail';
|
||||
this.resetReceiveSendForm();
|
||||
this.resetDepositForm();
|
||||
break;
|
||||
case 'lightning':
|
||||
case 'cashu':
|
||||
this.activeSection = 'main';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
selectConnection(connectionId: string) {
|
||||
this.selectedConnectionId = connectionId;
|
||||
this.activeSection = 'lightning-detail';
|
||||
this.loadTransactions(connectionId);
|
||||
}
|
||||
|
||||
private resetLightningForms() {
|
||||
this.lnReceiveAmount = 0;
|
||||
this.lnReceiveDescription = '';
|
||||
this.generatingInvoice = false;
|
||||
this.generatedInvoice = '';
|
||||
this.generatedInvoiceQr = '';
|
||||
this.lnReceiveError = '';
|
||||
this.invoiceCopied = false;
|
||||
this.payInput = '';
|
||||
this.payAmount = 0;
|
||||
this.paying = false;
|
||||
this.paymentSuccess = false;
|
||||
this.paymentError = '';
|
||||
this.showPayModal = false;
|
||||
}
|
||||
|
||||
showAddConnection() {
|
||||
this.resetAddForm();
|
||||
this.activeSection = 'lightning-add';
|
||||
}
|
||||
|
||||
private resetAddForm() {
|
||||
this.newWalletName = '';
|
||||
this.newWalletUrl = '';
|
||||
this.connectionError = '';
|
||||
this.connectionTestResult = '';
|
||||
this.addingConnection = false;
|
||||
this.testingConnection = false;
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
if (!this.newWalletUrl.trim()) {
|
||||
this.connectionError = 'Please enter an NWC URL';
|
||||
return;
|
||||
}
|
||||
|
||||
this.testingConnection = true;
|
||||
this.connectionError = '';
|
||||
this.connectionTestResult = '';
|
||||
this.nwcService.clearLogs();
|
||||
|
||||
try {
|
||||
const info = await this.nwcService.testConnection(this.newWalletUrl);
|
||||
this.connectionTestResult = `Connected! ${info.alias ? 'Wallet: ' + info.alias : ''}`;
|
||||
// Hide logs on success
|
||||
this.nwcService.clearLogs();
|
||||
} catch (error) {
|
||||
this.connectionError =
|
||||
error instanceof Error ? error.message : 'Connection test failed';
|
||||
// Keep logs visible on failure for debugging
|
||||
} finally {
|
||||
this.testingConnection = false;
|
||||
}
|
||||
}
|
||||
|
||||
async addConnection() {
|
||||
if (!this.newWalletName.trim()) {
|
||||
this.connectionError = 'Please enter a wallet name';
|
||||
return;
|
||||
}
|
||||
if (!this.newWalletUrl.trim()) {
|
||||
this.connectionError = 'Please enter an NWC URL';
|
||||
return;
|
||||
}
|
||||
|
||||
this.addingConnection = true;
|
||||
this.connectionError = '';
|
||||
|
||||
try {
|
||||
await this.nwcService.addConnection(
|
||||
this.newWalletName.trim(),
|
||||
this.newWalletUrl.trim()
|
||||
);
|
||||
|
||||
// Refresh the balance for the new connection
|
||||
const connections = this.nwcService.getConnections();
|
||||
const newConnection = connections[connections.length - 1];
|
||||
if (newConnection) {
|
||||
try {
|
||||
await this.nwcService.getBalance(newConnection.id);
|
||||
} catch {
|
||||
// Ignore balance fetch error
|
||||
}
|
||||
}
|
||||
|
||||
this.goBack();
|
||||
} catch (error) {
|
||||
this.connectionError =
|
||||
error instanceof Error ? error.message : 'Failed to add connection';
|
||||
} finally {
|
||||
this.addingConnection = false;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteConnection() {
|
||||
if (!this.selectedConnectionId) return;
|
||||
|
||||
const connection = this.selectedConnection;
|
||||
if (
|
||||
!confirm(`Delete wallet "${connection?.name}"? This cannot be undone.`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.nwcService.deleteConnection(this.selectedConnectionId);
|
||||
this.goBack();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete connection:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Cashu methods
|
||||
|
||||
selectMint(mintId: string) {
|
||||
this.selectedMintId = mintId;
|
||||
this.activeSection = 'cashu-detail';
|
||||
// Auto-refresh to check for spent proofs
|
||||
this.refreshMint();
|
||||
}
|
||||
|
||||
async refreshMint() {
|
||||
if (!this.selectedMintId || this.refreshingMint) return;
|
||||
|
||||
this.refreshingMint = true;
|
||||
this.refreshError = '';
|
||||
|
||||
try {
|
||||
const removedAmount = await this.cashuService.checkProofsSpent(this.selectedMintId);
|
||||
if (removedAmount > 0) {
|
||||
// Balance was updated, proofs were spent
|
||||
console.log(`Removed ${removedAmount} sats of spent proofs`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.refreshError = error instanceof Error ? error.message : 'Failed to refresh';
|
||||
console.error('Failed to refresh mint:', error);
|
||||
} finally {
|
||||
this.refreshingMint = false;
|
||||
}
|
||||
}
|
||||
|
||||
showAddMint() {
|
||||
this.resetAddMintForm();
|
||||
this.activeSection = 'cashu-add';
|
||||
}
|
||||
|
||||
showReceive() {
|
||||
this.resetReceiveSendForm();
|
||||
this.activeSection = 'cashu-receive';
|
||||
}
|
||||
|
||||
showSend() {
|
||||
this.resetReceiveSendForm();
|
||||
this.activeSection = 'cashu-send';
|
||||
}
|
||||
|
||||
private resetAddMintForm() {
|
||||
this.newMintName = '';
|
||||
this.newMintUrl = '';
|
||||
this.mintError = '';
|
||||
this.mintTestResult = '';
|
||||
this.addingMint = false;
|
||||
this.testingMint = false;
|
||||
}
|
||||
|
||||
private resetReceiveSendForm() {
|
||||
this.receiveToken = '';
|
||||
this.receivingToken = false;
|
||||
this.receiveError = '';
|
||||
this.receiveResult = '';
|
||||
this.sendAmount = 0;
|
||||
this.sendingToken = false;
|
||||
this.sendError = '';
|
||||
this.sendResult = '';
|
||||
}
|
||||
|
||||
private resetDepositForm() {
|
||||
this.depositAmount = 0;
|
||||
this.creatingDepositQuote = false;
|
||||
this.depositQuoteId = '';
|
||||
this.depositInvoice = '';
|
||||
this.depositInvoiceQr = '';
|
||||
this.depositError = '';
|
||||
this.depositSuccess = '';
|
||||
this.checkingDepositPayment = false;
|
||||
this.depositQuoteState = 'UNPAID';
|
||||
this.stopDepositPolling();
|
||||
}
|
||||
|
||||
private stopDepositPolling() {
|
||||
if (this.depositPollingInterval) {
|
||||
clearInterval(this.depositPollingInterval);
|
||||
this.depositPollingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async testMint() {
|
||||
if (!this.newMintUrl.trim()) {
|
||||
this.mintError = 'Please enter a mint URL';
|
||||
return;
|
||||
}
|
||||
|
||||
this.testingMint = true;
|
||||
this.mintError = '';
|
||||
this.mintTestResult = '';
|
||||
|
||||
try {
|
||||
const info = await this.cashuService.testMintConnection(
|
||||
this.newMintUrl.trim()
|
||||
);
|
||||
this.mintTestResult = `Connected! ${info.name ? 'Mint: ' + info.name : ''}`;
|
||||
} catch (error) {
|
||||
this.mintError =
|
||||
error instanceof Error ? error.message : 'Connection test failed';
|
||||
} finally {
|
||||
this.testingMint = false;
|
||||
}
|
||||
}
|
||||
|
||||
async addMint() {
|
||||
if (!this.newMintName.trim()) {
|
||||
this.mintError = 'Please enter a mint name';
|
||||
return;
|
||||
}
|
||||
if (!this.newMintUrl.trim()) {
|
||||
this.mintError = 'Please enter a mint URL';
|
||||
return;
|
||||
}
|
||||
|
||||
this.addingMint = true;
|
||||
this.mintError = '';
|
||||
|
||||
try {
|
||||
await this.cashuService.addMint(
|
||||
this.newMintName.trim(),
|
||||
this.newMintUrl.trim()
|
||||
);
|
||||
this.goBack();
|
||||
} catch (error) {
|
||||
this.mintError =
|
||||
error instanceof Error ? error.message : 'Failed to add mint';
|
||||
} finally {
|
||||
this.addingMint = false;
|
||||
}
|
||||
}
|
||||
|
||||
selectSuggestedMint(mint: { name: string; url: string }) {
|
||||
this.newMintName = mint.name;
|
||||
this.newMintUrl = mint.url;
|
||||
this.mintError = '';
|
||||
this.mintTestResult = '';
|
||||
}
|
||||
|
||||
isMintAlreadyAdded(mintUrl: string): boolean {
|
||||
return this.mints.some(m => m.mintUrl === mintUrl);
|
||||
}
|
||||
|
||||
hasUnavailableMints(): boolean {
|
||||
return this.suggestedMints.some(m => !this.isMintAlreadyAdded(m.url));
|
||||
}
|
||||
|
||||
async quickAddMint(mint: { name: string; url: string }) {
|
||||
this.addingMint = true;
|
||||
this.mintError = '';
|
||||
|
||||
try {
|
||||
await this.cashuService.addMint(mint.name, mint.url);
|
||||
} catch (error) {
|
||||
this.mintError =
|
||||
error instanceof Error ? error.message : 'Failed to add mint';
|
||||
} finally {
|
||||
this.addingMint = false;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMint() {
|
||||
if (!this.selectedMintId) return;
|
||||
|
||||
const mint = this.selectedMint;
|
||||
if (!confirm(`Delete mint "${mint?.name}"? Any tokens stored will be lost. This cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.cashuService.deleteMint(this.selectedMintId);
|
||||
this.goBack();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete mint:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async receiveTokens() {
|
||||
if (!this.receiveToken.trim()) {
|
||||
this.receiveError = 'Please paste a Cashu token';
|
||||
return;
|
||||
}
|
||||
|
||||
this.receivingToken = true;
|
||||
this.receiveError = '';
|
||||
this.receiveResult = '';
|
||||
|
||||
try {
|
||||
const result = await this.cashuService.receive(this.receiveToken.trim());
|
||||
this.receiveResult = `Received ${result.amount} sats!`;
|
||||
this.receiveToken = '';
|
||||
} catch (error) {
|
||||
this.receiveError =
|
||||
error instanceof Error ? error.message : 'Failed to receive token';
|
||||
} finally {
|
||||
this.receivingToken = false;
|
||||
}
|
||||
}
|
||||
|
||||
async sendTokens() {
|
||||
if (!this.selectedMintId) return;
|
||||
|
||||
if (this.sendAmount <= 0) {
|
||||
this.sendError = 'Please enter a valid amount';
|
||||
return;
|
||||
}
|
||||
|
||||
const balance = this.selectedMintBalance;
|
||||
if (this.sendAmount > balance) {
|
||||
this.sendError = `Insufficient balance. You have ${balance} sats`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendingToken = true;
|
||||
this.sendError = '';
|
||||
this.sendResult = '';
|
||||
|
||||
try {
|
||||
const result = await this.cashuService.send(
|
||||
this.selectedMintId,
|
||||
this.sendAmount
|
||||
);
|
||||
this.sendResult = result.token;
|
||||
this.sendAmount = 0;
|
||||
} catch (error) {
|
||||
this.sendError =
|
||||
error instanceof Error ? error.message : 'Failed to create token';
|
||||
} finally {
|
||||
this.sendingToken = false;
|
||||
}
|
||||
}
|
||||
|
||||
copyToken() {
|
||||
if (this.sendResult) {
|
||||
navigator.clipboard.writeText(this.sendResult);
|
||||
}
|
||||
}
|
||||
|
||||
async checkProofs() {
|
||||
if (!this.selectedMintId) return;
|
||||
|
||||
try {
|
||||
const removedAmount = await this.cashuService.checkProofsSpent(
|
||||
this.selectedMintId
|
||||
);
|
||||
if (removedAmount > 0) {
|
||||
alert(`Removed ${removedAmount} sats of spent proofs.`);
|
||||
} else {
|
||||
alert('All proofs are valid.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check proofs:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Cashu deposit (mint) methods
|
||||
|
||||
showDeposit() {
|
||||
this.resetDepositForm();
|
||||
this.activeSection = 'cashu-mint';
|
||||
}
|
||||
|
||||
async createDepositInvoice() {
|
||||
if (!this.selectedMintId) return;
|
||||
|
||||
if (this.depositAmount <= 0) {
|
||||
this.depositError = 'Please enter an amount';
|
||||
return;
|
||||
}
|
||||
|
||||
this.creatingDepositQuote = true;
|
||||
this.depositError = '';
|
||||
this.depositInvoice = '';
|
||||
this.depositInvoiceQr = '';
|
||||
|
||||
try {
|
||||
const quote = await this.cashuService.createMintQuote(
|
||||
this.selectedMintId,
|
||||
this.depositAmount
|
||||
);
|
||||
|
||||
this.depositQuoteId = quote.quoteId;
|
||||
this.depositInvoice = quote.invoice;
|
||||
this.depositQuoteState = quote.state;
|
||||
|
||||
// Generate QR code
|
||||
this.depositInvoiceQr = await QRCode.toDataURL(quote.invoice, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff',
|
||||
},
|
||||
});
|
||||
|
||||
// Start polling for payment
|
||||
this.startDepositPolling();
|
||||
} catch (error) {
|
||||
this.depositError =
|
||||
error instanceof Error ? error.message : 'Failed to create invoice';
|
||||
} finally {
|
||||
this.creatingDepositQuote = false;
|
||||
}
|
||||
}
|
||||
|
||||
private startDepositPolling() {
|
||||
// Poll every 3 seconds for payment confirmation
|
||||
this.depositPollingInterval = setInterval(async () => {
|
||||
await this.checkDepositPayment();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
async checkDepositPayment() {
|
||||
if (!this.selectedMintId || !this.depositQuoteId) return;
|
||||
|
||||
this.checkingDepositPayment = true;
|
||||
|
||||
try {
|
||||
const quote = await this.cashuService.checkMintQuote(
|
||||
this.selectedMintId,
|
||||
this.depositQuoteId
|
||||
);
|
||||
|
||||
this.depositQuoteState = quote.state;
|
||||
|
||||
if (quote.state === 'PAID') {
|
||||
// Invoice is paid, claim the tokens
|
||||
this.stopDepositPolling();
|
||||
await this.claimDepositTokens();
|
||||
} else if (quote.state === 'ISSUED') {
|
||||
// Already claimed
|
||||
this.stopDepositPolling();
|
||||
this.depositSuccess = 'Tokens already claimed!';
|
||||
}
|
||||
} catch (error) {
|
||||
// Don't show error for polling failures, just log
|
||||
console.error('Failed to check payment:', error);
|
||||
} finally {
|
||||
this.checkingDepositPayment = false;
|
||||
}
|
||||
}
|
||||
|
||||
async claimDepositTokens() {
|
||||
if (!this.selectedMintId || !this.depositQuoteId) return;
|
||||
|
||||
try {
|
||||
const result = await this.cashuService.mintTokens(
|
||||
this.selectedMintId,
|
||||
this.depositAmount,
|
||||
this.depositQuoteId
|
||||
);
|
||||
|
||||
this.depositSuccess = `Received ${result.amount} sats!`;
|
||||
this.depositQuoteState = 'ISSUED';
|
||||
} catch (error) {
|
||||
this.depositError =
|
||||
error instanceof Error ? error.message : 'Failed to claim tokens';
|
||||
}
|
||||
}
|
||||
|
||||
async copyDepositInvoice() {
|
||||
if (this.depositInvoice) {
|
||||
await navigator.clipboard.writeText(this.depositInvoice);
|
||||
}
|
||||
}
|
||||
|
||||
formatCashuBalance(sats: number | undefined): string {
|
||||
return this.cashuService.formatBalance(sats);
|
||||
}
|
||||
|
||||
async refreshBalance(connectionId: string) {
|
||||
try {
|
||||
await this.nwcService.getBalance(connectionId);
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh balance:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshAllBalances() {
|
||||
this.loadingBalances = true;
|
||||
this.balanceError = '';
|
||||
|
||||
try {
|
||||
await this.nwcService.getAllBalances();
|
||||
} catch {
|
||||
this.balanceError = 'Failed to refresh some balances';
|
||||
} finally {
|
||||
this.loadingBalances = false;
|
||||
}
|
||||
}
|
||||
|
||||
formatBalance(millisats: number | undefined): string {
|
||||
if (millisats === undefined) return '—';
|
||||
// Convert millisats to sats with 3 decimal places
|
||||
const sats = millisats / 1000;
|
||||
return sats.toLocaleString('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 3,
|
||||
});
|
||||
}
|
||||
|
||||
// Lightning transaction methods
|
||||
|
||||
async loadTransactions(connectionId: string) {
|
||||
this.loadingTransactions = true;
|
||||
this.transactionsError = '';
|
||||
this.transactionsNotSupported = false;
|
||||
|
||||
try {
|
||||
this.transactions = await this.nwcService.listTransactions(connectionId, {
|
||||
limit: 20,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
if (errorMsg.includes('NOT_IMPLEMENTED') || errorMsg.includes('not supported')) {
|
||||
this.transactionsNotSupported = true;
|
||||
} else {
|
||||
this.transactionsError = errorMsg;
|
||||
}
|
||||
this.transactions = [];
|
||||
} finally {
|
||||
this.loadingTransactions = false;
|
||||
}
|
||||
}
|
||||
|
||||
async refreshWallet() {
|
||||
if (!this.selectedConnectionId) return;
|
||||
|
||||
// Refresh balance and transactions in parallel
|
||||
await Promise.all([
|
||||
this.refreshBalance(this.selectedConnectionId),
|
||||
this.loadTransactions(this.selectedConnectionId),
|
||||
]);
|
||||
}
|
||||
|
||||
showLnReceive() {
|
||||
this.resetLightningForms();
|
||||
this.activeSection = 'lightning-receive';
|
||||
}
|
||||
|
||||
showLnPay() {
|
||||
this.resetLightningForms();
|
||||
this.showPayModal = true;
|
||||
}
|
||||
|
||||
closePayModal() {
|
||||
this.showPayModal = false;
|
||||
this.resetLightningForms();
|
||||
}
|
||||
|
||||
async createReceiveInvoice() {
|
||||
if (!this.selectedConnectionId) return;
|
||||
|
||||
if (this.lnReceiveAmount <= 0) {
|
||||
this.lnReceiveError = 'Please enter an amount';
|
||||
return;
|
||||
}
|
||||
|
||||
this.generatingInvoice = true;
|
||||
this.lnReceiveError = '';
|
||||
this.generatedInvoice = '';
|
||||
this.generatedInvoiceQr = '';
|
||||
|
||||
try {
|
||||
const result = await this.nwcService.makeInvoice(
|
||||
this.selectedConnectionId,
|
||||
this.lnReceiveAmount * 1000, // Convert sats to millisats
|
||||
this.lnReceiveDescription || undefined
|
||||
);
|
||||
this.generatedInvoice = result.invoice;
|
||||
|
||||
// Generate QR code
|
||||
this.generatedInvoiceQr = await QRCode.toDataURL(result.invoice, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.lnReceiveError =
|
||||
error instanceof Error ? error.message : 'Failed to create invoice';
|
||||
} finally {
|
||||
this.generatingInvoice = false;
|
||||
}
|
||||
}
|
||||
|
||||
async copyInvoice() {
|
||||
if (this.generatedInvoice) {
|
||||
await navigator.clipboard.writeText(this.generatedInvoice);
|
||||
this.invoiceCopied = true;
|
||||
setTimeout(() => (this.invoiceCopied = false), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
async copyLightningAddress() {
|
||||
const lud16 = this.selectedConnection?.lud16;
|
||||
if (lud16) {
|
||||
await navigator.clipboard.writeText(lud16);
|
||||
this.addressCopied = true;
|
||||
setTimeout(() => (this.addressCopied = false), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
async payInvoiceOrAddress() {
|
||||
if (!this.selectedConnectionId || !this.payInput.trim()) {
|
||||
this.paymentError = 'Please enter a lightning address or invoice';
|
||||
return;
|
||||
}
|
||||
|
||||
this.paying = true;
|
||||
this.paymentError = '';
|
||||
this.paymentSuccess = false;
|
||||
|
||||
try {
|
||||
let invoice = this.payInput.trim();
|
||||
|
||||
// Check if it's a lightning address
|
||||
if (this.nwcService.isLightningAddress(invoice)) {
|
||||
if (this.payAmount <= 0) {
|
||||
this.paymentError = 'Please enter an amount for lightning address payments';
|
||||
this.paying = false;
|
||||
return;
|
||||
}
|
||||
// Resolve lightning address to invoice
|
||||
invoice = await this.nwcService.resolveLightningAddress(
|
||||
invoice,
|
||||
this.payAmount * 1000 // Convert sats to millisats
|
||||
);
|
||||
}
|
||||
|
||||
// Pay the invoice
|
||||
await this.nwcService.payInvoice(
|
||||
this.selectedConnectionId,
|
||||
invoice,
|
||||
this.payAmount > 0 ? this.payAmount * 1000 : undefined
|
||||
);
|
||||
|
||||
this.paymentSuccess = true;
|
||||
|
||||
// Refresh balance and transactions after payment
|
||||
await this.refreshWallet();
|
||||
|
||||
// Close modal after a delay
|
||||
setTimeout(() => {
|
||||
this.closePayModal();
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
this.paymentError =
|
||||
error instanceof Error ? error.message : 'Payment failed';
|
||||
} finally {
|
||||
this.paying = false;
|
||||
}
|
||||
}
|
||||
|
||||
formatTransactionTime(timestamp: number): string {
|
||||
const date = new Date(timestamp * 1000);
|
||||
const now = new Date();
|
||||
const isToday = date.toDateString() === now.toDateString();
|
||||
|
||||
if (isToday) {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
formatProofTime(isoTimestamp: string | undefined): string {
|
||||
if (!isoTimestamp) return '—';
|
||||
|
||||
const date = new Date(isoTimestamp);
|
||||
const now = new Date();
|
||||
const isToday = date.toDateString() === now.toDateString();
|
||||
|
||||
if (isToday) {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
async onClickLock() {
|
||||
this.#logger.logVaultLock();
|
||||
await this.storage.lockVault();
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
}
|
||||
|
||||
// Cashu onboarding methods
|
||||
dismissCashuInfo() {
|
||||
this.showCashuInfo = false;
|
||||
}
|
||||
|
||||
navigateToSettings() {
|
||||
this.#router.navigateByUrl('/home/settings');
|
||||
}
|
||||
}
|
||||
@@ -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,32 +1,120 @@
|
||||
<div class="vertically-centered">
|
||||
<div class="sam-flex-column center">
|
||||
<div class="sam-flex-column gap" style="align-items: center">
|
||||
<span class="title">Plebeian Signer</span>
|
||||
<div class="container">
|
||||
<div class="logo-section">
|
||||
<div class="logo-frame">
|
||||
<img src="logo.svg" height="80" width="80" alt="" />
|
||||
</div>
|
||||
<span class="title">Plebeian Signer</span>
|
||||
</div>
|
||||
|
||||
<div class="logo-frame">
|
||||
<img src="logo.svg" height="120" width="120" alt="" />
|
||||
</div>
|
||||
<!-- New Identity Section -->
|
||||
<div class="section">
|
||||
<h2 class="section-heading">Restore or Create New Identity</h2>
|
||||
|
||||
<span class="section-note">Create a new nostr identity or paste in your current nsec.</span>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="nickname"
|
||||
[(ngModel)]="nickname"
|
||||
/>
|
||||
|
||||
<div class="input-group">
|
||||
<input
|
||||
#nsecInputElement
|
||||
type="password"
|
||||
class="form-control"
|
||||
placeholder="nsec or hex private key"
|
||||
[(ngModel)]="nsecInput"
|
||||
(ngModelChange)="validateNsec()"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
type="button"
|
||||
(click)="toggleVisibility(nsecInputElement)"
|
||||
title="toggle visibility"
|
||||
>
|
||||
<i
|
||||
class="bi"
|
||||
[class.bi-eye]="nsecInputElement.type === 'password'"
|
||||
[class.bi-eye-slash]="nsecInputElement.type === 'text'"
|
||||
></i>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
type="button"
|
||||
(click)="copyToClipboard()"
|
||||
title="copy to clipboard"
|
||||
>
|
||||
<i class="bi bi-clipboard"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button
|
||||
type="button"
|
||||
class="sam-mt-2 btn btn-primary"
|
||||
(click)="router.navigateByUrl('/vault-create/new')"
|
||||
class="btn btn-outline-secondary generate-btn"
|
||||
(click)="generateKey()"
|
||||
title="generate new key"
|
||||
>
|
||||
<div class="sam-flex-row gap-h">
|
||||
<i class="bi bi-plus-circle" style="height: 22px"></i>
|
||||
<span>Create a new vault</span>
|
||||
</div>
|
||||
<span>generate</span>
|
||||
<span>✨</span>
|
||||
</button>
|
||||
|
||||
<span class="sam-text-muted">or</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
(click)="router.navigateByUrl('/vault-import')"
|
||||
class="btn btn-primary continue-btn"
|
||||
[disabled]="!isNsecValid || !nickname"
|
||||
(click)="onContinueWithNsec()"
|
||||
>
|
||||
<span>Import a vault</span>
|
||||
<span>Continue</span>
|
||||
<i class="bi bi-arrow-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Section -->
|
||||
<div class="section">
|
||||
<h2 class="section-heading">Import a Vault</h2>
|
||||
|
||||
<input
|
||||
#fileInput
|
||||
type="file"
|
||||
class="file-input"
|
||||
accept=".json"
|
||||
(change)="onFileSelected($event)"
|
||||
/>
|
||||
|
||||
<div class="import-controls">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary file-btn"
|
||||
(click)="fileInput.click()"
|
||||
>
|
||||
<i class="bi bi-folder2-open"></i>
|
||||
<span>Add vault file</span>
|
||||
</button>
|
||||
|
||||
@if (snapshots.length > 0) {
|
||||
<div class="import-row">
|
||||
<select class="form-select" [(ngModel)]="selectedSnapshot">
|
||||
@for (snapshot of snapshots; track snapshot.id) {
|
||||
<option [ngValue]="snapshot">
|
||||
{{ snapshot.fileName }} ({{ snapshot.identityCount }} identities)
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary icon-btn"
|
||||
[disabled]="!selectedSnapshot"
|
||||
(click)="onImport()"
|
||||
title="import vault"
|
||||
>
|
||||
<i class="bi bi-arrow-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,18 +2,26 @@
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
|
||||
.vertically-centered {
|
||||
height: 100%;
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
padding: var(--size);
|
||||
gap: var(--size);
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--size-half);
|
||||
padding-bottom: var(--size-half);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
margin-bottom: var(--size);
|
||||
}
|
||||
|
||||
.logo-frame {
|
||||
@@ -21,8 +29,73 @@
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size);
|
||||
margin-top: var(--size);
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-note {
|
||||
font-size: 14px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: var(--size);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.generate-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size-half);
|
||||
}
|
||||
|
||||
.continue-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size-half);
|
||||
}
|
||||
|
||||
.file-input {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.file-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size-half);
|
||||
}
|
||||
|
||||
.import-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size);
|
||||
}
|
||||
|
||||
.import-row {
|
||||
display: flex;
|
||||
gap: var(--size-half);
|
||||
|
||||
select {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 42px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,161 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { NavComponent } from '@common';
|
||||
import {
|
||||
NavComponent,
|
||||
NostrHelper,
|
||||
StorageService,
|
||||
StartupService,
|
||||
SignerMetaData_VaultSnapshot,
|
||||
BrowserSyncData,
|
||||
} from '@common';
|
||||
import { generateSecretKey } from 'nostr-tools';
|
||||
import { bytesToHex } from '@noble/hashes/utils';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import browser from 'webextension-polyfill';
|
||||
import { getNewStorageServiceConfig } from '../../../common/data/get-new-storage-service-config';
|
||||
|
||||
const VAULT_SNAPSHOTS_KEY = 'vaultSnapshots';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
imports: [],
|
||||
imports: [FormsModule],
|
||||
templateUrl: './home.component.html',
|
||||
styleUrl: './home.component.scss',
|
||||
})
|
||||
export class HomeComponent extends NavComponent {
|
||||
export class HomeComponent extends NavComponent implements OnInit {
|
||||
readonly router = inject(Router);
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #startup = inject(StartupService);
|
||||
|
||||
nickname = '';
|
||||
nsecInput = '';
|
||||
isNsecValid = false;
|
||||
snapshots: SignerMetaData_VaultSnapshot[] = [];
|
||||
selectedSnapshot: SignerMetaData_VaultSnapshot | undefined;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.#loadSnapshots();
|
||||
}
|
||||
|
||||
generateKey() {
|
||||
const sk = generateSecretKey();
|
||||
const privkey = bytesToHex(sk);
|
||||
this.nsecInput = NostrHelper.privkey2nsec(privkey);
|
||||
this.validateNsec();
|
||||
}
|
||||
|
||||
toggleVisibility(element: HTMLInputElement) {
|
||||
element.type = element.type === 'password' ? 'text' : 'password';
|
||||
}
|
||||
|
||||
async copyToClipboard() {
|
||||
if (this.nsecInput) {
|
||||
await navigator.clipboard.writeText(this.nsecInput);
|
||||
}
|
||||
}
|
||||
|
||||
validateNsec() {
|
||||
if (!this.nsecInput) {
|
||||
this.isNsecValid = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
NostrHelper.getNostrPrivkeyObject(this.nsecInput.toLowerCase());
|
||||
this.isNsecValid = true;
|
||||
} catch {
|
||||
this.isNsecValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
onContinueWithNsec() {
|
||||
if (!this.isNsecValid || !this.nickname) {
|
||||
return;
|
||||
}
|
||||
// Navigate to password step, passing nsec and nickname in state
|
||||
this.router.navigateByUrl('/vault-create/new', {
|
||||
state: { nsec: this.nsecInput, nickname: this.nickname },
|
||||
});
|
||||
}
|
||||
|
||||
async onFileSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = input.files;
|
||||
if (!files || files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const file = files[0];
|
||||
const text = await file.text();
|
||||
const vault = JSON.parse(text) as BrowserSyncData;
|
||||
|
||||
// Check if file already exists
|
||||
if (this.snapshots.some((s) => s.fileName === file.name)) {
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const newSnapshot: SignerMetaData_VaultSnapshot = {
|
||||
id: uuidv4(),
|
||||
fileName: file.name,
|
||||
createdAt: new Date().toISOString(),
|
||||
data: vault,
|
||||
identityCount: vault.identities?.length ?? 0,
|
||||
reason: 'manual',
|
||||
};
|
||||
|
||||
this.snapshots = [...this.snapshots, newSnapshot].sort((a, b) =>
|
||||
b.fileName.localeCompare(a.fileName)
|
||||
);
|
||||
this.selectedSnapshot = newSnapshot;
|
||||
|
||||
await this.#saveSnapshots();
|
||||
} catch (error) {
|
||||
console.error('Failed to load vault file:', error);
|
||||
}
|
||||
|
||||
// Reset input so same file can be selected again
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
async onImport() {
|
||||
if (!this.selectedSnapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.#storage.deleteVault(true);
|
||||
await this.#storage.importVault(this.selectedSnapshot.data);
|
||||
|
||||
// Restart the app to properly reinitialize and route to vault-login
|
||||
this.#storage.isInitialized = false;
|
||||
this.#startup.startOver(getNewStorageServiceConfig());
|
||||
} catch (error) {
|
||||
console.error('Failed to import vault:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async #loadSnapshots() {
|
||||
const data = (await browser.storage.local.get(VAULT_SNAPSHOTS_KEY)) as {
|
||||
vaultSnapshots?: SignerMetaData_VaultSnapshot[];
|
||||
};
|
||||
|
||||
this.snapshots = data.vaultSnapshots
|
||||
? [...data.vaultSnapshots].sort((a, b) =>
|
||||
b.fileName.localeCompare(a.fileName)
|
||||
)
|
||||
: [];
|
||||
|
||||
if (this.snapshots.length > 0) {
|
||||
this.selectedSnapshot = this.snapshots[0];
|
||||
}
|
||||
}
|
||||
|
||||
async #saveSnapshots() {
|
||||
await browser.storage.local.set({
|
||||
[VAULT_SNAPSHOTS_KEY]: this.snapshots,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<app-deriving-modal #derivingModal></app-deriving-modal>
|
||||
|
||||
<div class="sam-text-header">
|
||||
<span>Plebeian Signer</span>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,36 @@
|
||||
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);
|
||||
|
||||
// Access router state via history.state (persists after navigation completes)
|
||||
get #nsec(): string | undefined {
|
||||
return history.state?.nsec;
|
||||
}
|
||||
|
||||
get #nickname(): string | undefined {
|
||||
return history.state?.nickname;
|
||||
}
|
||||
|
||||
toggleType(element: HTMLInputElement) {
|
||||
if (element.type === 'password') {
|
||||
@@ -28,7 +45,29 @@ 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.#logger.logVaultCreated();
|
||||
|
||||
// If nsec and nickname were passed, add the identity
|
||||
if (this.#nsec && this.#nickname) {
|
||||
try {
|
||||
await this.#storage.addIdentity({
|
||||
nick: this.#nickname,
|
||||
privkeyString: this.#nsec,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to add identity:', error);
|
||||
}
|
||||
}
|
||||
|
||||
this.derivingModal.hide();
|
||||
this.#router.navigateByUrl('/home/identity');
|
||||
} 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) {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<div class="sam-text-header sam-mb-h">
|
||||
<span>Plebeian Signer Setup - Sync Preference</span>
|
||||
</div>
|
||||
|
||||
<span class="sam-text-muted sam-text-md sam-text-align-center2">
|
||||
Plebeian Signer always encrypts sensitive data like private keys and site permissions
|
||||
independent of the chosen sync mode.
|
||||
</span>
|
||||
|
||||
<span class="sam-mt sam-text-lg">Sync : Google Chrome</span>
|
||||
|
||||
<span class="sam-text-muted sam-text-md sam-text-align-center2">
|
||||
Your encrypted data is synced between browser instances. You need to be signed
|
||||
in with your account.
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="sam-mt btn btn-primary"
|
||||
(click)="onClickSync(true)"
|
||||
>
|
||||
<span> Sync ON</span>
|
||||
</button>
|
||||
|
||||
<span class="sam-mt sam-text-lg">Offline</span>
|
||||
|
||||
<span class="sam-text-muted sam-text-md">
|
||||
Your encrypted data is never uploaded to any servers. It remains in your local
|
||||
browser instance.
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="sam-mt sam-mb-2 btn btn-secondary"
|
||||
(click)="onClickSync(false)"
|
||||
>
|
||||
<span> Sync OFF</span>
|
||||
</button>
|
||||
|
||||
<div class="sam-flex-grow"></div>
|
||||
|
||||
<span class="sam-text-muted sam-text-md sam-mb">
|
||||
Your preference can later be changed at any time.
|
||||
</span>
|
||||
@@ -1,8 +0,0 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { WelcomeComponent } from './welcome.component';
|
||||
|
||||
describe('WelcomeComponent', () => {
|
||||
let component: WelcomeComponent;
|
||||
let fixture: ComponentFixture<WelcomeComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [WelcomeComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(WelcomeComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { BrowserSyncFlow, StorageService } from '@common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-welcome',
|
||||
imports: [],
|
||||
templateUrl: './welcome.component.html',
|
||||
styleUrl: './welcome.component.scss',
|
||||
})
|
||||
export class WelcomeComponent {
|
||||
readonly router = inject(Router);
|
||||
readonly #storage = inject(StorageService);
|
||||
|
||||
async onClickSync(enabled: boolean) {
|
||||
const flow: BrowserSyncFlow = enabled
|
||||
? BrowserSyncFlow.BROWSER_SYNC
|
||||
: BrowserSyncFlow.NO_SYNC;
|
||||
|
||||
await this.#storage.enableBrowserSyncFlow(flow);
|
||||
|
||||
// In case the user has selected the BROWSER_SYNC flow,
|
||||
// we have to check if there is sync data available (e.g. from
|
||||
// another browser instance).
|
||||
// If so, navigate to /vault-login, otherwise to /vault-create/home.
|
||||
if (flow === BrowserSyncFlow.BROWSER_SYNC) {
|
||||
const browserSyncData =
|
||||
await this.#storage.loadAndMigrateBrowserSyncData();
|
||||
|
||||
if (
|
||||
typeof browserSyncData !== 'undefined' &&
|
||||
Object.keys(browserSyncData).length > 0
|
||||
) {
|
||||
await this.router.navigateByUrl('/vault-login');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.router.navigateByUrl('/vault-create/home');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
override 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');
|
||||
}
|
||||
}
|
||||
@@ -6,25 +6,55 @@ import {
|
||||
CryptoHelper,
|
||||
SignerMetaData,
|
||||
Identity_DECRYPTED,
|
||||
Identity_ENCRYPTED,
|
||||
Nip07Method,
|
||||
Nip07MethodPolicy,
|
||||
NostrHelper,
|
||||
Permission_DECRYPTED,
|
||||
Permission_ENCRYPTED,
|
||||
Relay_DECRYPTED,
|
||||
Relay_ENCRYPTED,
|
||||
NwcConnection_DECRYPTED,
|
||||
NwcConnection_ENCRYPTED,
|
||||
CashuMint_DECRYPTED,
|
||||
CashuMint_ENCRYPTED,
|
||||
deriveKeyArgon2,
|
||||
ExtensionMethod,
|
||||
WeblnMethod,
|
||||
} 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();
|
||||
console.log(`[Plebeian Signer - ${dateString}]: ${JSON.stringify(message)}`);
|
||||
};
|
||||
// Unlock request/response message types
|
||||
export interface UnlockRequestMessage {
|
||||
type: 'unlock-request';
|
||||
id: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface UnlockResponseMessage {
|
||||
type: 'unlock-response';
|
||||
id: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Debug logging disabled - uncomment for development
|
||||
// export const debug = function (message: any) {
|
||||
// const dateString = new Date().toISOString();
|
||||
// console.log(`[Plebeian Signer - ${dateString}]: ${JSON.stringify(message)}`);
|
||||
// };
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function
|
||||
export const debug = function (_message: any) {};
|
||||
|
||||
export type PromptResponse =
|
||||
| 'reject'
|
||||
| 'reject-once'
|
||||
| 'reject-all' // P2: Reject all requests of this type from this host
|
||||
| 'approve'
|
||||
| 'approve-once';
|
||||
| 'approve-once'
|
||||
| 'approve-all'; // P2: Approve all requests of this type from this host
|
||||
|
||||
export interface PromptResponseMessage {
|
||||
id: string;
|
||||
@@ -32,7 +62,7 @@ export interface PromptResponseMessage {
|
||||
}
|
||||
|
||||
export interface BackgroundRequestMessage {
|
||||
method: Nip07Method;
|
||||
method: ExtensionMethod;
|
||||
params: any;
|
||||
host: string;
|
||||
}
|
||||
@@ -48,6 +78,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
|
||||
> {
|
||||
@@ -159,11 +225,51 @@ export const checkPermissions = function (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a method is a WebLN method
|
||||
*/
|
||||
export const isWeblnMethod = function (method: ExtensionMethod): method is WeblnMethod {
|
||||
return method.startsWith('webln.');
|
||||
};
|
||||
|
||||
/**
|
||||
* Check WebLN permissions for a host.
|
||||
* Note: WebLN permissions are NOT tied to identities since the wallet is global.
|
||||
* For sendPayment, always returns undefined (require user prompt for security).
|
||||
*/
|
||||
export const checkWeblnPermissions = function (
|
||||
browserSessionData: BrowserSessionData,
|
||||
host: string,
|
||||
method: WeblnMethod
|
||||
): boolean | undefined {
|
||||
// sendPayment ALWAYS requires user approval (security-critical, irreversible)
|
||||
if (method === 'webln.sendPayment') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// keysend also requires approval
|
||||
if (method === 'webln.keysend') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// For other WebLN methods, check stored permissions
|
||||
// WebLN permissions use 'webln' as the identityId
|
||||
const permissions = browserSessionData.permissions.filter(
|
||||
(x) => x.identityId === 'webln' && x.host === host && x.method === method
|
||||
);
|
||||
|
||||
if (permissions.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return permissions.every((x) => x.methodPolicy === 'allow');
|
||||
};
|
||||
|
||||
export const storePermission = async function (
|
||||
browserSessionData: BrowserSessionData,
|
||||
identity: Identity_DECRYPTED,
|
||||
identity: Identity_DECRYPTED | null,
|
||||
host: string,
|
||||
method: Nip07Method,
|
||||
method: ExtensionMethod,
|
||||
methodPolicy: Nip07MethodPolicy,
|
||||
kind?: number
|
||||
) {
|
||||
@@ -172,11 +278,14 @@ export const storePermission = async function (
|
||||
throw new Error(`Could not retrieve sync data`);
|
||||
}
|
||||
|
||||
// For WebLN methods, use 'webln' as identityId since wallet is global
|
||||
const identityId = identity?.id ?? 'webln';
|
||||
|
||||
const permission: Permission_DECRYPTED = {
|
||||
id: crypto.randomUUID(),
|
||||
identityId: identity.id,
|
||||
identityId,
|
||||
host,
|
||||
method,
|
||||
method: method as Nip07Method, // Cast for storage compatibility
|
||||
methodPolicy,
|
||||
kind,
|
||||
};
|
||||
@@ -189,8 +298,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 +395,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 +417,379 @@ const encryptPermission = async function (
|
||||
|
||||
const encrypt = async function (
|
||||
value: string,
|
||||
iv: string,
|
||||
sessionData: BrowserSessionData
|
||||
): Promise<string> {
|
||||
// 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!);
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// Unlock Vault Logic (for background script)
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Decrypt a value using AES-GCM with pre-derived key (v2)
|
||||
*/
|
||||
async function 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a value using PBKDF2 (v1)
|
||||
*/
|
||||
async function decryptV1(
|
||||
encryptedBase64: string,
|
||||
ivBase64: string,
|
||||
password: string
|
||||
): Promise<string> {
|
||||
return await CryptoHelper.encrypt(value, iv, password);
|
||||
};
|
||||
return CryptoHelper.decrypt(encryptedBase64, ivBase64, password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic decrypt function that handles both v1 and v2
|
||||
*/
|
||||
async function decryptValue(
|
||||
encrypted: string,
|
||||
iv: string,
|
||||
keyOrPassword: string,
|
||||
isV2: boolean
|
||||
): Promise<string> {
|
||||
if (isV2) {
|
||||
return decryptV2(encrypted, iv, keyOrPassword);
|
||||
}
|
||||
return decryptV1(encrypted, iv, keyOrPassword);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse decrypted value to the desired type
|
||||
*/
|
||||
function parseValue(value: string, type: 'string' | 'number' | 'boolean'): any {
|
||||
switch (type) {
|
||||
case 'number':
|
||||
return parseInt(value);
|
||||
case 'boolean':
|
||||
return value === 'true';
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt an identity
|
||||
*/
|
||||
async function decryptIdentity(
|
||||
identity: Identity_ENCRYPTED,
|
||||
iv: string,
|
||||
keyOrPassword: string,
|
||||
isV2: boolean
|
||||
): Promise<Identity_DECRYPTED> {
|
||||
return {
|
||||
id: await decryptValue(identity.id, iv, keyOrPassword, isV2),
|
||||
nick: await decryptValue(identity.nick, iv, keyOrPassword, isV2),
|
||||
createdAt: await decryptValue(identity.createdAt, iv, keyOrPassword, isV2),
|
||||
privkey: await decryptValue(identity.privkey, iv, keyOrPassword, isV2),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a permission
|
||||
*/
|
||||
async function decryptPermission(
|
||||
permission: Permission_ENCRYPTED,
|
||||
iv: string,
|
||||
keyOrPassword: string,
|
||||
isV2: boolean
|
||||
): Promise<Permission_DECRYPTED> {
|
||||
const decrypted: Permission_DECRYPTED = {
|
||||
id: await decryptValue(permission.id, iv, keyOrPassword, isV2),
|
||||
identityId: await decryptValue(permission.identityId, iv, keyOrPassword, isV2),
|
||||
host: await decryptValue(permission.host, iv, keyOrPassword, isV2),
|
||||
method: await decryptValue(permission.method, iv, keyOrPassword, isV2) as Nip07Method,
|
||||
methodPolicy: await decryptValue(permission.methodPolicy, iv, keyOrPassword, isV2) as Nip07MethodPolicy,
|
||||
};
|
||||
if (permission.kind) {
|
||||
decrypted.kind = parseValue(await decryptValue(permission.kind, iv, keyOrPassword, isV2), 'number');
|
||||
}
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a relay
|
||||
*/
|
||||
async function decryptRelay(
|
||||
relay: Relay_ENCRYPTED,
|
||||
iv: string,
|
||||
keyOrPassword: string,
|
||||
isV2: boolean
|
||||
): Promise<Relay_DECRYPTED> {
|
||||
return {
|
||||
id: await decryptValue(relay.id, iv, keyOrPassword, isV2),
|
||||
identityId: await decryptValue(relay.identityId, iv, keyOrPassword, isV2),
|
||||
url: await decryptValue(relay.url, iv, keyOrPassword, isV2),
|
||||
read: parseValue(await decryptValue(relay.read, iv, keyOrPassword, isV2), 'boolean'),
|
||||
write: parseValue(await decryptValue(relay.write, iv, keyOrPassword, isV2), 'boolean'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt an NWC connection
|
||||
*/
|
||||
async function decryptNwcConnection(
|
||||
nwc: NwcConnection_ENCRYPTED,
|
||||
iv: string,
|
||||
keyOrPassword: string,
|
||||
isV2: boolean
|
||||
): Promise<NwcConnection_DECRYPTED> {
|
||||
const decrypted: NwcConnection_DECRYPTED = {
|
||||
id: await decryptValue(nwc.id, iv, keyOrPassword, isV2),
|
||||
name: await decryptValue(nwc.name, iv, keyOrPassword, isV2),
|
||||
connectionUrl: await decryptValue(nwc.connectionUrl, iv, keyOrPassword, isV2),
|
||||
walletPubkey: await decryptValue(nwc.walletPubkey, iv, keyOrPassword, isV2),
|
||||
relayUrl: await decryptValue(nwc.relayUrl, iv, keyOrPassword, isV2),
|
||||
secret: await decryptValue(nwc.secret, iv, keyOrPassword, isV2),
|
||||
createdAt: await decryptValue(nwc.createdAt, iv, keyOrPassword, isV2),
|
||||
};
|
||||
if (nwc.lud16) {
|
||||
decrypted.lud16 = await decryptValue(nwc.lud16, iv, keyOrPassword, isV2);
|
||||
}
|
||||
if (nwc.cachedBalance) {
|
||||
decrypted.cachedBalance = parseValue(await decryptValue(nwc.cachedBalance, iv, keyOrPassword, isV2), 'number');
|
||||
}
|
||||
if (nwc.cachedBalanceAt) {
|
||||
decrypted.cachedBalanceAt = await decryptValue(nwc.cachedBalanceAt, iv, keyOrPassword, isV2);
|
||||
}
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a Cashu mint
|
||||
*/
|
||||
async function decryptCashuMint(
|
||||
mint: CashuMint_ENCRYPTED,
|
||||
iv: string,
|
||||
keyOrPassword: string,
|
||||
isV2: boolean
|
||||
): Promise<CashuMint_DECRYPTED> {
|
||||
const proofsJson = await decryptValue(mint.proofs, iv, keyOrPassword, isV2);
|
||||
const decrypted: CashuMint_DECRYPTED = {
|
||||
id: await decryptValue(mint.id, iv, keyOrPassword, isV2),
|
||||
name: await decryptValue(mint.name, iv, keyOrPassword, isV2),
|
||||
mintUrl: await decryptValue(mint.mintUrl, iv, keyOrPassword, isV2),
|
||||
unit: await decryptValue(mint.unit, iv, keyOrPassword, isV2),
|
||||
createdAt: await decryptValue(mint.createdAt, iv, keyOrPassword, isV2),
|
||||
proofs: JSON.parse(proofsJson),
|
||||
};
|
||||
if (mint.cachedBalance) {
|
||||
decrypted.cachedBalance = parseValue(await decryptValue(mint.cachedBalance, iv, keyOrPassword, isV2), 'number');
|
||||
}
|
||||
if (mint.cachedBalanceAt) {
|
||||
decrypted.cachedBalanceAt = await decryptValue(mint.cachedBalanceAt, iv, keyOrPassword, isV2);
|
||||
}
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an unlock request from the unlock popup
|
||||
*/
|
||||
export async function handleUnlockRequest(
|
||||
password: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
debug('handleUnlockRequest: Starting unlock...');
|
||||
|
||||
// Check if already unlocked
|
||||
const existingSession = await getBrowserSessionData();
|
||||
if (existingSession) {
|
||||
debug('handleUnlockRequest: Already unlocked');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Get sync data
|
||||
const browserSyncData = await getBrowserSyncData();
|
||||
if (!browserSyncData) {
|
||||
return { success: false, error: 'No vault data found' };
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const passwordHash = await CryptoHelper.hash(password);
|
||||
if (passwordHash !== browserSyncData.vaultHash) {
|
||||
return { success: false, error: 'Invalid password' };
|
||||
}
|
||||
debug('handleUnlockRequest: Password verified');
|
||||
|
||||
// Detect vault version
|
||||
const isV2 = !!browserSyncData.salt;
|
||||
debug(`handleUnlockRequest: Vault version: ${isV2 ? 'v2' : 'v1'}`);
|
||||
|
||||
let keyOrPassword: string;
|
||||
let vaultKey: string | undefined;
|
||||
let vaultPassword: string | undefined;
|
||||
|
||||
if (isV2) {
|
||||
// v2: Derive key with Argon2id (~3 seconds)
|
||||
debug('handleUnlockRequest: Deriving Argon2id key...');
|
||||
const saltBytes = Buffer.from(browserSyncData.salt!, 'base64');
|
||||
const keyBytes = await deriveKeyArgon2(password, saltBytes);
|
||||
vaultKey = Buffer.from(keyBytes).toString('base64');
|
||||
keyOrPassword = vaultKey;
|
||||
debug('handleUnlockRequest: Key derived');
|
||||
} else {
|
||||
// v1: Use password directly
|
||||
vaultPassword = password;
|
||||
keyOrPassword = password;
|
||||
}
|
||||
|
||||
// Decrypt identities
|
||||
debug('handleUnlockRequest: Decrypting identities...');
|
||||
const decryptedIdentities: Identity_DECRYPTED[] = [];
|
||||
for (const identity of browserSyncData.identities) {
|
||||
const decrypted = await decryptIdentity(identity, browserSyncData.iv, keyOrPassword, isV2);
|
||||
decryptedIdentities.push(decrypted);
|
||||
}
|
||||
debug(`handleUnlockRequest: Decrypted ${decryptedIdentities.length} identities`);
|
||||
|
||||
// Decrypt permissions
|
||||
debug('handleUnlockRequest: Decrypting permissions...');
|
||||
const decryptedPermissions: Permission_DECRYPTED[] = [];
|
||||
for (const permission of browserSyncData.permissions) {
|
||||
try {
|
||||
const decrypted = await decryptPermission(permission, browserSyncData.iv, keyOrPassword, isV2);
|
||||
decryptedPermissions.push(decrypted);
|
||||
} catch (e) {
|
||||
debug(`handleUnlockRequest: Skipping corrupted permission: ${e}`);
|
||||
}
|
||||
}
|
||||
debug(`handleUnlockRequest: Decrypted ${decryptedPermissions.length} permissions`);
|
||||
|
||||
// Decrypt relays
|
||||
debug('handleUnlockRequest: Decrypting relays...');
|
||||
const decryptedRelays: Relay_DECRYPTED[] = [];
|
||||
for (const relay of browserSyncData.relays) {
|
||||
const decrypted = await decryptRelay(relay, browserSyncData.iv, keyOrPassword, isV2);
|
||||
decryptedRelays.push(decrypted);
|
||||
}
|
||||
debug(`handleUnlockRequest: Decrypted ${decryptedRelays.length} relays`);
|
||||
|
||||
// Decrypt NWC connections
|
||||
debug('handleUnlockRequest: Decrypting NWC connections...');
|
||||
const decryptedNwcConnections: NwcConnection_DECRYPTED[] = [];
|
||||
for (const nwc of browserSyncData.nwcConnections ?? []) {
|
||||
const decrypted = await decryptNwcConnection(nwc, browserSyncData.iv, keyOrPassword, isV2);
|
||||
decryptedNwcConnections.push(decrypted);
|
||||
}
|
||||
debug(`handleUnlockRequest: Decrypted ${decryptedNwcConnections.length} NWC connections`);
|
||||
|
||||
// Decrypt Cashu mints
|
||||
debug('handleUnlockRequest: Decrypting Cashu mints...');
|
||||
const decryptedCashuMints: CashuMint_DECRYPTED[] = [];
|
||||
for (const mint of browserSyncData.cashuMints ?? []) {
|
||||
const decrypted = await decryptCashuMint(mint, browserSyncData.iv, keyOrPassword, isV2);
|
||||
decryptedCashuMints.push(decrypted);
|
||||
}
|
||||
debug(`handleUnlockRequest: Decrypted ${decryptedCashuMints.length} Cashu mints`);
|
||||
|
||||
// Decrypt selectedIdentityId
|
||||
let decryptedSelectedIdentityId: string | null = null;
|
||||
if (browserSyncData.selectedIdentityId !== null) {
|
||||
decryptedSelectedIdentityId = await decryptValue(
|
||||
browserSyncData.selectedIdentityId,
|
||||
browserSyncData.iv,
|
||||
keyOrPassword,
|
||||
isV2
|
||||
);
|
||||
}
|
||||
debug(`handleUnlockRequest: selectedIdentityId: ${decryptedSelectedIdentityId}`);
|
||||
|
||||
// Build session data
|
||||
const browserSessionData: BrowserSessionData = {
|
||||
vaultPassword: isV2 ? undefined : vaultPassword,
|
||||
vaultKey: isV2 ? vaultKey : undefined,
|
||||
iv: browserSyncData.iv,
|
||||
salt: browserSyncData.salt,
|
||||
permissions: decryptedPermissions,
|
||||
identities: decryptedIdentities,
|
||||
selectedIdentityId: decryptedSelectedIdentityId,
|
||||
relays: decryptedRelays,
|
||||
nwcConnections: decryptedNwcConnections,
|
||||
cashuMints: decryptedCashuMints,
|
||||
};
|
||||
|
||||
// Save session data
|
||||
debug('handleUnlockRequest: Saving session data...');
|
||||
await chrome.storage.session.set(browserSessionData);
|
||||
debug('handleUnlockRequest: Unlock complete!');
|
||||
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
debug(`handleUnlockRequest: Error: ${error.message}`);
|
||||
return { success: false, error: error.message || 'Unlock failed' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the unlock popup window
|
||||
*/
|
||||
export async function openUnlockPopup(host?: string): Promise<void> {
|
||||
const width = 375;
|
||||
const height = 500;
|
||||
const { top, left } = await getPosition(width, height);
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
let url = `unlock.html?id=${id}`;
|
||||
if (host) {
|
||||
url += `&host=${encodeURIComponent(host)}`;
|
||||
}
|
||||
|
||||
await chrome.windows.create({
|
||||
type: 'popup',
|
||||
url,
|
||||
height,
|
||||
width,
|
||||
top,
|
||||
left,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,35 +1,336 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { NostrHelper } from '@common';
|
||||
import {
|
||||
NostrHelper,
|
||||
NwcClient,
|
||||
NwcConnection_DECRYPTED,
|
||||
WeblnMethod,
|
||||
Nip07Method,
|
||||
GetInfoResponse,
|
||||
SendPaymentResponse,
|
||||
RequestInvoiceResponse,
|
||||
} from '@common';
|
||||
import {
|
||||
BackgroundRequestMessage,
|
||||
checkPermissions,
|
||||
checkWeblnPermissions,
|
||||
debug,
|
||||
getBrowserSessionData,
|
||||
getPosition,
|
||||
handleUnlockRequest,
|
||||
isWeblnMethod,
|
||||
nip04Decrypt,
|
||||
nip04Encrypt,
|
||||
nip44Decrypt,
|
||||
nip44Encrypt,
|
||||
openUnlockPopup,
|
||||
PromptResponse,
|
||||
PromptResponseMessage,
|
||||
shouldRecklessModeApprove,
|
||||
signEvent,
|
||||
storePermission,
|
||||
UnlockRequestMessage,
|
||||
UnlockResponseMessage,
|
||||
} from './background-common';
|
||||
import browser from 'webextension-polyfill';
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
// Cache for NWC clients to avoid reconnecting for each request
|
||||
const nwcClientCache = new Map<string, NwcClient>();
|
||||
|
||||
/**
|
||||
* Get or create an NWC client for a connection
|
||||
*/
|
||||
async function getNwcClient(connection: NwcConnection_DECRYPTED): Promise<NwcClient> {
|
||||
const cached = nwcClientCache.get(connection.id);
|
||||
if (cached && cached.isConnected()) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const client = new NwcClient({
|
||||
walletPubkey: connection.walletPubkey,
|
||||
relayUrl: connection.relayUrl,
|
||||
secret: connection.secret,
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
nwcClientCache.set(connection.id, client);
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse invoice amount from a BOLT11 invoice string
|
||||
* Returns amount in satoshis, or undefined if no amount specified
|
||||
*/
|
||||
function parseInvoiceAmount(invoice: string): number | undefined {
|
||||
try {
|
||||
// BOLT11 invoices start with 'ln' followed by network prefix and amount
|
||||
// Format: ln[network][amount][multiplier]1[data]
|
||||
// Examples: lnbc1500n1... (1500 sat), lnbc1m1... (0.001 BTC = 100000 sat)
|
||||
const match = invoice.toLowerCase().match(/^ln(bc|tb|tbs|bcrt)(\d+)([munp])?1/);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const amountStr = match[2];
|
||||
const multiplier = match[3];
|
||||
|
||||
let amount = parseInt(amountStr, 10);
|
||||
|
||||
// Apply multiplier (amount is in BTC by default)
|
||||
switch (multiplier) {
|
||||
case 'm': // milli-bitcoin (0.001 BTC)
|
||||
amount = amount * 100000;
|
||||
break;
|
||||
case 'u': // micro-bitcoin (0.000001 BTC)
|
||||
amount = amount * 100;
|
||||
break;
|
||||
case 'n': // nano-bitcoin (0.000000001 BTC) = 0.1 sat
|
||||
amount = Math.floor(amount / 10);
|
||||
break;
|
||||
case 'p': // pico-bitcoin (0.000000000001 BTC) = 0.0001 sat
|
||||
amount = Math.floor(amount / 10000);
|
||||
break;
|
||||
default:
|
||||
// No multiplier means BTC
|
||||
amount = amount * 100000000;
|
||||
}
|
||||
|
||||
return amount;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
type Relays = Record<string, { read: boolean; write: boolean }>;
|
||||
|
||||
// ==========================================
|
||||
// Permission Prompt Queue System (P0)
|
||||
// ==========================================
|
||||
|
||||
// Timeout for permission prompts (30 seconds)
|
||||
const PROMPT_TIMEOUT_MS = 30000;
|
||||
|
||||
// Maximum number of queued permission requests (prevent DoS)
|
||||
const MAX_PERMISSION_QUEUE_SIZE = 100;
|
||||
|
||||
// Track open prompts with metadata for cleanup
|
||||
const openPrompts = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (response: PromptResponse) => void;
|
||||
reject: (reason?: any) => void;
|
||||
windowId?: number;
|
||||
timeoutId?: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
>();
|
||||
|
||||
// Track if unlock popup is already open
|
||||
let unlockPopupOpen = false;
|
||||
|
||||
// Queue of pending NIP-07 requests waiting for unlock
|
||||
const pendingRequests: {
|
||||
request: BackgroundRequestMessage;
|
||||
resolve: (result: any) => void;
|
||||
reject: (error: any) => void;
|
||||
}[] = [];
|
||||
|
||||
// Queue for permission requests (only one prompt shown at a time)
|
||||
interface PermissionQueueItem {
|
||||
id: string;
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
resolve: (response: PromptResponse) => void;
|
||||
reject: (reason?: any) => void;
|
||||
}
|
||||
|
||||
const permissionQueue: PermissionQueueItem[] = [];
|
||||
let activePromptId: string | null = null;
|
||||
|
||||
/**
|
||||
* Show the next permission prompt from the queue
|
||||
*/
|
||||
async function showNextPermissionPrompt(): Promise<void> {
|
||||
if (activePromptId || permissionQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = permissionQueue[0];
|
||||
activePromptId = next.id;
|
||||
|
||||
const { top, left } = await getPosition(next.width, next.height);
|
||||
|
||||
try {
|
||||
const window = await browser.windows.create({
|
||||
type: 'popup',
|
||||
url: next.url,
|
||||
height: next.height,
|
||||
width: next.width,
|
||||
top,
|
||||
left,
|
||||
});
|
||||
|
||||
const promptData = openPrompts.get(next.id);
|
||||
if (promptData && window.id) {
|
||||
promptData.windowId = window.id;
|
||||
promptData.timeoutId = setTimeout(() => {
|
||||
debug(`Prompt ${next.id} timed out after ${PROMPT_TIMEOUT_MS}ms`);
|
||||
cleanupPrompt(next.id, 'timeout');
|
||||
}, PROMPT_TIMEOUT_MS);
|
||||
}
|
||||
} catch (error) {
|
||||
debug(`Failed to create prompt window: ${error}`);
|
||||
cleanupPrompt(next.id, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up a prompt and process the next one in queue
|
||||
*/
|
||||
function cleanupPrompt(promptId: string, reason: 'response' | 'timeout' | 'closed' | 'error'): void {
|
||||
const promptData = openPrompts.get(promptId);
|
||||
|
||||
if (promptData) {
|
||||
if (promptData.timeoutId) {
|
||||
clearTimeout(promptData.timeoutId);
|
||||
}
|
||||
if (reason !== 'response') {
|
||||
promptData.reject(new Error(`Permission prompt ${reason}`));
|
||||
}
|
||||
openPrompts.delete(promptId);
|
||||
}
|
||||
|
||||
const queueIndex = permissionQueue.findIndex(item => item.id === promptId);
|
||||
if (queueIndex !== -1) {
|
||||
permissionQueue.splice(queueIndex, 1);
|
||||
}
|
||||
|
||||
if (activePromptId === promptId) {
|
||||
activePromptId = null;
|
||||
}
|
||||
|
||||
showNextPermissionPrompt();
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a permission prompt request
|
||||
*/
|
||||
function queuePermissionPrompt(
|
||||
urlWithoutId: string,
|
||||
width: number,
|
||||
height: number
|
||||
): Promise<PromptResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (permissionQueue.length >= MAX_PERMISSION_QUEUE_SIZE) {
|
||||
reject(new Error('Too many pending permission requests. Please try again later.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const separator = urlWithoutId.includes('?') ? '&' : '?';
|
||||
const url = `${urlWithoutId}${separator}id=${id}`;
|
||||
|
||||
openPrompts.set(id, { resolve, reject });
|
||||
permissionQueue.push({ id, url, width, height, resolve, reject });
|
||||
|
||||
debug(`Queued permission prompt ${id}. Queue size: ${permissionQueue.length}`);
|
||||
showNextPermissionPrompt();
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for window close events to clean up orphaned prompts
|
||||
browser.windows.onRemoved.addListener((windowId: number) => {
|
||||
for (const [promptId, promptData] of openPrompts.entries()) {
|
||||
if (promptData.windowId === windowId) {
|
||||
debug(`Prompt window ${windowId} closed without response`);
|
||||
cleanupPrompt(promptId, 'closed');
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// Request Deduplication (P1)
|
||||
// ==========================================
|
||||
|
||||
const pendingRequestPromises = new Map<string, Promise<PromptResponse>>();
|
||||
|
||||
/**
|
||||
* Generate a hash key for request deduplication
|
||||
*/
|
||||
function getRequestHash(host: string, method: string, params: any): string {
|
||||
if (method === 'signEvent' && params?.kind !== undefined) {
|
||||
return `${host}:${method}:kind${params.kind}`;
|
||||
}
|
||||
if ((method.includes('encrypt') || method.includes('decrypt')) && params?.peerPubkey) {
|
||||
return `${host}:${method}:${params.peerPubkey}`;
|
||||
}
|
||||
return `${host}:${method}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a permission prompt with deduplication
|
||||
*/
|
||||
function queuePermissionPromptDeduped(
|
||||
host: string,
|
||||
method: string,
|
||||
params: any,
|
||||
urlWithoutId: string,
|
||||
width: number,
|
||||
height: number
|
||||
): Promise<PromptResponse> {
|
||||
const hash = getRequestHash(host, method, params);
|
||||
|
||||
const existingPromise = pendingRequestPromises.get(hash);
|
||||
if (existingPromise) {
|
||||
debug(`Deduplicating request: ${hash}`);
|
||||
return existingPromise;
|
||||
}
|
||||
|
||||
const promise = queuePermissionPrompt(urlWithoutId, width, height)
|
||||
.finally(() => {
|
||||
pendingRequestPromises.delete(hash);
|
||||
});
|
||||
|
||||
pendingRequestPromises.set(hash, promise);
|
||||
debug(`New permission request: ${hash}`);
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
||||
debug('Message received');
|
||||
|
||||
// Handle unlock request from unlock popup
|
||||
if ((message as UnlockRequestMessage)?.type === 'unlock-request') {
|
||||
const unlockReq = message as UnlockRequestMessage;
|
||||
debug('Processing unlock request');
|
||||
const result = await handleUnlockRequest(unlockReq.password);
|
||||
const response: UnlockResponseMessage = {
|
||||
type: 'unlock-response',
|
||||
id: unlockReq.id,
|
||||
success: result.success,
|
||||
error: result.error,
|
||||
};
|
||||
|
||||
if (result.success) {
|
||||
unlockPopupOpen = false;
|
||||
// Process any pending NIP-07 requests
|
||||
debug(`Processing ${pendingRequests.length} pending requests`);
|
||||
while (pendingRequests.length > 0) {
|
||||
const pending = pendingRequests.shift()!;
|
||||
try {
|
||||
const pendingResult = await processNip07Request(pending.request);
|
||||
pending.resolve(pendingResult);
|
||||
} catch (error) {
|
||||
pending.reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
const request = message as BackgroundRequestMessage | PromptResponseMessage;
|
||||
debug(request);
|
||||
|
||||
@@ -38,18 +339,47 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
||||
const promptResponse = request as PromptResponseMessage;
|
||||
const openPrompt = openPrompts.get(promptResponse.id);
|
||||
if (!openPrompt) {
|
||||
throw new Error(
|
||||
'Prompt response could not be matched to any previous request.'
|
||||
);
|
||||
debug('Prompt response could not be matched (may have timed out)');
|
||||
return;
|
||||
}
|
||||
|
||||
openPrompt.resolve(promptResponse.response);
|
||||
openPrompts.delete(promptResponse.id);
|
||||
cleanupPrompt(promptResponse.id, 'response');
|
||||
return;
|
||||
}
|
||||
|
||||
const browserSessionData = await getBrowserSessionData();
|
||||
|
||||
if (!browserSessionData) {
|
||||
// Vault is locked - open unlock popup and queue the request
|
||||
const req = request as BackgroundRequestMessage;
|
||||
debug('Vault locked, opening unlock popup');
|
||||
|
||||
if (!unlockPopupOpen) {
|
||||
unlockPopupOpen = true;
|
||||
await openUnlockPopup(req.host);
|
||||
}
|
||||
|
||||
// Queue this request to be processed after unlock
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingRequests.push({ request: req, resolve, reject });
|
||||
});
|
||||
}
|
||||
|
||||
// Process the request (NIP-07 or WebLN)
|
||||
const req = request as BackgroundRequestMessage;
|
||||
if (isWeblnMethod(req.method)) {
|
||||
return processWeblnRequest(req);
|
||||
}
|
||||
return processNip07Request(req);
|
||||
});
|
||||
|
||||
/**
|
||||
* Process a NIP-07 request after vault is unlocked
|
||||
*/
|
||||
async function processNip07Request(req: BackgroundRequestMessage): Promise<any> {
|
||||
const browserSessionData = await getBrowserSessionData();
|
||||
|
||||
if (!browserSessionData) {
|
||||
throw new Error('Plebeian Signer vault not unlocked by the user.');
|
||||
}
|
||||
@@ -62,61 +392,85 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
||||
throw new Error('No Nostr identity available at endpoint.');
|
||||
}
|
||||
|
||||
const req = request as BackgroundRequestMessage;
|
||||
const permissionState = checkPermissions(
|
||||
browserSessionData,
|
||||
currentIdentity,
|
||||
req.host,
|
||||
req.method,
|
||||
req.params
|
||||
);
|
||||
// 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 as Nip07Method,
|
||||
req.params
|
||||
);
|
||||
debug(`permissionState result: ${permissionState}`);
|
||||
|
||||
if (permissionState === false) {
|
||||
throw new Error('Permission denied');
|
||||
}
|
||||
|
||||
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 (queued + deduplicated)
|
||||
const width = 375;
|
||||
const height = 600;
|
||||
|
||||
const base64Event = Buffer.from(
|
||||
JSON.stringify(req.params ?? {}, undefined, 2)
|
||||
).toString('base64');
|
||||
|
||||
// Include queue info for user awareness
|
||||
const queueSize = permissionQueue.length;
|
||||
const promptUrl = `prompt.html?method=${req.method}&host=${req.host}&nick=${encodeURIComponent(currentIdentity.nick)}&event=${base64Event}&queue=${queueSize}`;
|
||||
const response = await queuePermissionPromptDeduped(req.host, req.method, req.params, promptUrl, width, height);
|
||||
debug(response);
|
||||
|
||||
// Handle permission storage based on response type
|
||||
if (response === 'approve' || response === 'reject') {
|
||||
// Store permission for this specific kind (if signEvent) or method
|
||||
const policy = response === 'approve' ? 'allow' : 'deny';
|
||||
await storePermission(
|
||||
browserSessionData,
|
||||
currentIdentity,
|
||||
req.host,
|
||||
req.method,
|
||||
policy,
|
||||
req.params?.kind
|
||||
);
|
||||
} else if (response === 'approve-all') {
|
||||
// P2: Store permission for ALL kinds/uses of this method from this host
|
||||
await storePermission(
|
||||
browserSessionData,
|
||||
currentIdentity,
|
||||
req.host,
|
||||
req.method,
|
||||
'allow',
|
||||
undefined // undefined kind = allow all kinds for signEvent
|
||||
);
|
||||
} else if (response === 'reject-all') {
|
||||
// P2: Store deny permission for ALL uses of this method from this host
|
||||
await storePermission(
|
||||
browserSessionData,
|
||||
currentIdentity,
|
||||
req.host,
|
||||
req.method,
|
||||
'deny',
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
if (['reject', 'reject-once', 'reject-all'].includes(response)) {
|
||||
throw new Error('Permission denied');
|
||||
}
|
||||
} else {
|
||||
debug('Request allowed (via saved permission).');
|
||||
}
|
||||
}
|
||||
|
||||
const relays: Relays = {};
|
||||
|
||||
switch (req.method) {
|
||||
case 'getPublicKey':
|
||||
return NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey);
|
||||
@@ -161,4 +515,146 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
||||
default:
|
||||
throw new Error(`Not supported request method '${req.method}'.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a WebLN request after vault is unlocked
|
||||
*/
|
||||
async function processWeblnRequest(req: BackgroundRequestMessage): Promise<any> {
|
||||
const browserSessionData = await getBrowserSessionData();
|
||||
|
||||
if (!browserSessionData) {
|
||||
throw new Error('Plebeian Signer vault not unlocked by the user.');
|
||||
}
|
||||
|
||||
const nwcConnections = browserSessionData.nwcConnections ?? [];
|
||||
const method = req.method as WeblnMethod;
|
||||
|
||||
// webln.enable just checks if NWC is configured
|
||||
if (method === 'webln.enable') {
|
||||
if (nwcConnections.length === 0) {
|
||||
throw new Error('No wallet configured. Please add an NWC connection in Plebeian Signer settings.');
|
||||
}
|
||||
debug('WebLN enabled');
|
||||
return { enabled: true }; // Return explicit value (undefined gets filtered by content script)
|
||||
}
|
||||
|
||||
// All other methods require an NWC connection
|
||||
const defaultConnection = nwcConnections[0];
|
||||
if (!defaultConnection) {
|
||||
throw new Error('No wallet configured. Please add an NWC connection in Plebeian Signer settings.');
|
||||
}
|
||||
|
||||
// Check reckless mode (but still prompt for payments)
|
||||
const recklessApprove = await shouldRecklessModeApprove(req.host);
|
||||
|
||||
// Check WebLN permissions
|
||||
const permissionState = recklessApprove && method !== 'webln.sendPayment' && method !== 'webln.keysend'
|
||||
? true
|
||||
: checkWeblnPermissions(browserSessionData, req.host, method);
|
||||
|
||||
if (permissionState === false) {
|
||||
throw new Error('Permission denied');
|
||||
}
|
||||
|
||||
if (permissionState === undefined) {
|
||||
// Ask user for permission (queued + deduplicated)
|
||||
const width = 375;
|
||||
const height = 600;
|
||||
|
||||
// For sendPayment, include the invoice amount in the prompt data
|
||||
let promptParams = req.params ?? {};
|
||||
if (method === 'webln.sendPayment' && req.params?.paymentRequest) {
|
||||
const amountSats = parseInvoiceAmount(req.params.paymentRequest);
|
||||
promptParams = { ...promptParams, amountSats };
|
||||
}
|
||||
|
||||
const base64Event = Buffer.from(
|
||||
JSON.stringify(promptParams, undefined, 2)
|
||||
).toString('base64');
|
||||
|
||||
// Include queue info for user awareness
|
||||
const queueSize = permissionQueue.length;
|
||||
const promptUrl = `prompt.html?method=${method}&host=${req.host}&nick=WebLN&event=${base64Event}&queue=${queueSize}`;
|
||||
const response = await queuePermissionPromptDeduped(req.host, method, req.params, promptUrl, width, height);
|
||||
|
||||
debug(response);
|
||||
|
||||
// Store permission for non-payment methods
|
||||
if ((response === 'approve' || response === 'reject') && method !== 'webln.sendPayment' && method !== 'webln.keysend') {
|
||||
const policy = response === 'approve' ? 'allow' : 'deny';
|
||||
await storePermission(
|
||||
browserSessionData,
|
||||
null, // WebLN has no identity
|
||||
req.host,
|
||||
method,
|
||||
policy
|
||||
);
|
||||
} else if (response === 'approve-all' && method !== 'webln.sendPayment' && method !== 'webln.keysend') {
|
||||
// P2: Store permission for all uses of this WebLN method
|
||||
await storePermission(
|
||||
browserSessionData,
|
||||
null,
|
||||
req.host,
|
||||
method,
|
||||
'allow'
|
||||
);
|
||||
}
|
||||
|
||||
if (['reject', 'reject-once', 'reject-all'].includes(response)) {
|
||||
throw new Error('Permission denied');
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the WebLN method
|
||||
let result: any;
|
||||
const client = await getNwcClient(defaultConnection);
|
||||
|
||||
switch (method) {
|
||||
case 'webln.getInfo': {
|
||||
const info = await client.getInfo();
|
||||
result = {
|
||||
node: {
|
||||
alias: info.alias,
|
||||
pubkey: info.pubkey,
|
||||
color: info.color,
|
||||
},
|
||||
} as GetInfoResponse;
|
||||
debug('webln.getInfo result:');
|
||||
debug(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'webln.sendPayment': {
|
||||
const invoice = req.params.paymentRequest;
|
||||
const payResult = await client.payInvoice({ invoice });
|
||||
result = { preimage: payResult.preimage } as SendPaymentResponse;
|
||||
debug('webln.sendPayment result:');
|
||||
debug(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'webln.makeInvoice': {
|
||||
// Convert sats to millisats (NWC uses millisats)
|
||||
const amountSats = typeof req.params.amount === 'string'
|
||||
? parseInt(req.params.amount, 10)
|
||||
: req.params.amount ?? req.params.defaultAmount ?? 0;
|
||||
const amountMsat = amountSats * 1000;
|
||||
|
||||
const invoiceResult = await client.makeInvoice({
|
||||
amount: amountMsat,
|
||||
description: req.params.defaultMemo,
|
||||
});
|
||||
result = { paymentRequest: invoiceResult.invoice } as RequestInvoiceResponse;
|
||||
debug('webln.makeInvoice result:');
|
||||
debug(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'webln.keysend':
|
||||
throw new Error('keysend is not yet supported');
|
||||
|
||||
default:
|
||||
throw new Error(`Not supported WebLN method '${method}'.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '@common';
|
||||
import './app/common/extensions/array';
|
||||
import browser from 'webextension-polyfill';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
//
|
||||
// Functions
|
||||
@@ -105,8 +106,12 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
}
|
||||
|
||||
newSnapshots.push({
|
||||
id: uuidv4(),
|
||||
fileName: file.name,
|
||||
createdAt: new Date().toISOString(),
|
||||
data: vault,
|
||||
identityCount: vault.identities?.length ?? 0,
|
||||
reason: 'manual',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Event, EventTemplate } from 'nostr-tools';
|
||||
import { Nip07Method } from '@common';
|
||||
import { Event as NostrEvent, EventTemplate } from 'nostr-tools';
|
||||
import { ExtensionMethod } from '@common';
|
||||
|
||||
// Extend Window interface for NIP-07 and WebLN
|
||||
declare global {
|
||||
interface Window {
|
||||
nostr?: any;
|
||||
webln?: any;
|
||||
}
|
||||
}
|
||||
|
||||
type Relays = Record<string, { read: boolean; write: boolean }>;
|
||||
|
||||
@@ -31,7 +39,7 @@ class Messenger {
|
||||
window.addEventListener('message', this.#handleCallResponse.bind(this));
|
||||
}
|
||||
|
||||
async request(method: Nip07Method, params: any): Promise<any> {
|
||||
async request(method: ExtensionMethod, params: any): Promise<any> {
|
||||
const id = generateUUID();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -82,7 +90,7 @@ const nostr = {
|
||||
return pubkey;
|
||||
},
|
||||
|
||||
async signEvent(event: EventTemplate): Promise<Event> {
|
||||
async signEvent(event: EventTemplate): Promise<NostrEvent> {
|
||||
debug('signEvent received');
|
||||
const signedEvent = await this.messenger.request('signEvent', event);
|
||||
debug('signEvent response:');
|
||||
@@ -151,6 +159,92 @@ const nostr = {
|
||||
|
||||
window.nostr = nostr as any;
|
||||
|
||||
// WebLN types (inline to avoid build issues with @common types in injected script)
|
||||
interface RequestInvoiceArgs {
|
||||
amount?: string | number;
|
||||
defaultAmount?: string | number;
|
||||
minimumAmount?: string | number;
|
||||
maximumAmount?: string | number;
|
||||
defaultMemo?: string;
|
||||
}
|
||||
|
||||
interface KeysendArgs {
|
||||
destination: string;
|
||||
amount: string | number;
|
||||
customRecords?: Record<string, string>;
|
||||
}
|
||||
|
||||
// Create a shared messenger instance for WebLN
|
||||
const weblnMessenger = nostr.messenger;
|
||||
|
||||
const webln = {
|
||||
enabled: false,
|
||||
|
||||
async enable(): Promise<void> {
|
||||
debug('webln.enable received');
|
||||
await weblnMessenger.request('webln.enable', {});
|
||||
this.enabled = true;
|
||||
debug('webln.enable completed');
|
||||
// Dispatch webln:enabled event as per WebLN spec
|
||||
window.dispatchEvent(new Event('webln:enabled'));
|
||||
},
|
||||
|
||||
async getInfo(): Promise<{ node: { alias?: string; pubkey?: string; color?: string } }> {
|
||||
debug('webln.getInfo received');
|
||||
const info = await weblnMessenger.request('webln.getInfo', {});
|
||||
debug('webln.getInfo response:');
|
||||
debug(info);
|
||||
return info;
|
||||
},
|
||||
|
||||
async sendPayment(paymentRequest: string): Promise<{ preimage: string }> {
|
||||
debug('webln.sendPayment received');
|
||||
const result = await weblnMessenger.request('webln.sendPayment', { paymentRequest });
|
||||
debug('webln.sendPayment response:');
|
||||
debug(result);
|
||||
return result;
|
||||
},
|
||||
|
||||
async keysend(args: KeysendArgs): Promise<{ preimage: string }> {
|
||||
debug('webln.keysend received');
|
||||
const result = await weblnMessenger.request('webln.keysend', args);
|
||||
debug('webln.keysend response:');
|
||||
debug(result);
|
||||
return result;
|
||||
},
|
||||
|
||||
async makeInvoice(
|
||||
args: string | number | RequestInvoiceArgs
|
||||
): Promise<{ paymentRequest: string }> {
|
||||
debug('webln.makeInvoice received');
|
||||
// Normalize args to RequestInvoiceArgs
|
||||
let normalizedArgs: RequestInvoiceArgs;
|
||||
if (typeof args === 'string' || typeof args === 'number') {
|
||||
normalizedArgs = { amount: args };
|
||||
} else {
|
||||
normalizedArgs = args;
|
||||
}
|
||||
const result = await weblnMessenger.request('webln.makeInvoice', normalizedArgs);
|
||||
debug('webln.makeInvoice response:');
|
||||
debug(result);
|
||||
return result;
|
||||
},
|
||||
|
||||
signMessage(): Promise<{ message: string; signature: string }> {
|
||||
throw new Error('signMessage is not supported - NWC does not provide node signing capabilities');
|
||||
},
|
||||
|
||||
verifyMessage(): Promise<void> {
|
||||
throw new Error('verifyMessage is not supported - NWC does not provide message verification');
|
||||
},
|
||||
};
|
||||
|
||||
window.webln = webln as any;
|
||||
|
||||
// Dispatch webln:ready event to signal that webln is available
|
||||
// This is dispatched on document as per the WebLN standard
|
||||
document.dispatchEvent(new Event('webln:ready'));
|
||||
|
||||
const debug = function (value: any) {
|
||||
console.log(JSON.stringify(value));
|
||||
};
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
import browser from 'webextension-polyfill';
|
||||
import { Buffer } from 'buffer';
|
||||
import { Nip07Method } from '@common';
|
||||
import { ExtensionMethod } 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 method = params.get('method') as ExtensionMethod;
|
||||
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) {
|
||||
@@ -40,6 +58,26 @@ switch (method) {
|
||||
title = 'Get Relays';
|
||||
break;
|
||||
|
||||
case 'webln.enable':
|
||||
title = 'Enable WebLN';
|
||||
break;
|
||||
|
||||
case 'webln.getInfo':
|
||||
title = 'Wallet Info';
|
||||
break;
|
||||
|
||||
case 'webln.sendPayment':
|
||||
title = 'Send Payment';
|
||||
break;
|
||||
|
||||
case 'webln.makeInvoice':
|
||||
title = 'Create Invoice';
|
||||
break;
|
||||
|
||||
case 'webln.keysend':
|
||||
title = 'Keysend Payment';
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -62,8 +100,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 +146,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 +163,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 +179,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 +196,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';
|
||||
@@ -171,40 +205,118 @@ if (cardNip44DecryptElement && card2Nip44DecryptElement) {
|
||||
}
|
||||
}
|
||||
|
||||
// WebLN card visibility
|
||||
const cardWeblnEnableElement = document.getElementById('cardWeblnEnable');
|
||||
if (cardWeblnEnableElement) {
|
||||
if (method !== 'webln.enable') {
|
||||
cardWeblnEnableElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const cardWeblnGetInfoElement = document.getElementById('cardWeblnGetInfo');
|
||||
if (cardWeblnGetInfoElement) {
|
||||
if (method !== 'webln.getInfo') {
|
||||
cardWeblnGetInfoElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const cardWeblnSendPaymentElement = document.getElementById('cardWeblnSendPayment');
|
||||
const card2WeblnSendPaymentElement = document.getElementById('card2WeblnSendPayment');
|
||||
if (cardWeblnSendPaymentElement && card2WeblnSendPaymentElement) {
|
||||
if (method === 'webln.sendPayment') {
|
||||
// Display amount in sats
|
||||
const paymentAmountSpan = document.getElementById('paymentAmountSpan');
|
||||
if (paymentAmountSpan && eventParsed.amountSats !== undefined) {
|
||||
paymentAmountSpan.innerText = `${eventParsed.amountSats.toLocaleString()} sats`;
|
||||
} else if (paymentAmountSpan) {
|
||||
paymentAmountSpan.innerText = 'unknown amount';
|
||||
}
|
||||
// Show invoice in json card
|
||||
const card2WeblnSendPayment_jsonElement = document.getElementById('card2WeblnSendPayment_json');
|
||||
if (card2WeblnSendPayment_jsonElement && eventParsed.paymentRequest) {
|
||||
card2WeblnSendPayment_jsonElement.innerText = eventParsed.paymentRequest;
|
||||
}
|
||||
} else {
|
||||
cardWeblnSendPaymentElement.style.display = 'none';
|
||||
card2WeblnSendPaymentElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const cardWeblnMakeInvoiceElement = document.getElementById('cardWeblnMakeInvoice');
|
||||
if (cardWeblnMakeInvoiceElement) {
|
||||
if (method === 'webln.makeInvoice') {
|
||||
const invoiceAmountSpan = document.getElementById('invoiceAmountSpan');
|
||||
if (invoiceAmountSpan) {
|
||||
const amount = eventParsed.amount ?? eventParsed.defaultAmount;
|
||||
if (amount) {
|
||||
invoiceAmountSpan.innerText = ` for ${Number(amount).toLocaleString()} sats`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cardWeblnMakeInvoiceElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const cardWeblnKeysendElement = document.getElementById('cardWeblnKeysend');
|
||||
if (cardWeblnKeysendElement) {
|
||||
if (method !== 'webln.keysend') {
|
||||
cardWeblnKeysendElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// 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');
|
||||
});
|
||||
|
||||
const rejectAllButton = document.getElementById('rejectAllButton');
|
||||
rejectAllButton?.addEventListener('click', () => {
|
||||
deliver('reject-all');
|
||||
});
|
||||
|
||||
const approveAllButton = document.getElementById('approveAllButton');
|
||||
approveAllButton?.addEventListener('click', () => {
|
||||
deliver('approve-all');
|
||||
});
|
||||
|
||||
// Show/hide "All Queued" row based on queue size
|
||||
const queueSize = parseInt(params.get('queueSize') || '0', 10);
|
||||
const allQueuedRow = document.getElementById('allQueuedRow');
|
||||
if (allQueuedRow && queueSize <= 1) {
|
||||
allQueuedRow.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
106
projects/chrome/src/unlock.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import browser from 'webextension-polyfill';
|
||||
|
||||
export interface UnlockRequestMessage {
|
||||
type: 'unlock-request';
|
||||
id: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface UnlockResponseMessage {
|
||||
type: 'unlock-response';
|
||||
id: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const id = params.get('id') as string;
|
||||
const host = params.get('host');
|
||||
|
||||
// Elements
|
||||
const passwordInput = document.getElementById('passwordInput') as HTMLInputElement;
|
||||
const togglePasswordBtn = document.getElementById('togglePassword');
|
||||
const unlockBtn = document.getElementById('unlockBtn') as HTMLButtonElement;
|
||||
const derivingOverlay = document.getElementById('derivingOverlay');
|
||||
const errorAlert = document.getElementById('errorAlert');
|
||||
const errorMessage = document.getElementById('errorMessage');
|
||||
const hostInfo = document.getElementById('hostInfo');
|
||||
const hostSpan = document.getElementById('hostSpan');
|
||||
|
||||
// Show host info if available
|
||||
if (host && hostInfo && hostSpan) {
|
||||
hostSpan.innerText = host;
|
||||
hostInfo.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Toggle password visibility
|
||||
togglePasswordBtn?.addEventListener('click', () => {
|
||||
if (passwordInput.type === 'password') {
|
||||
passwordInput.type = 'text';
|
||||
togglePasswordBtn.innerHTML = '<i class="bi bi-eye-slash"></i>';
|
||||
} else {
|
||||
passwordInput.type = 'password';
|
||||
togglePasswordBtn.innerHTML = '<i class="bi bi-eye"></i>';
|
||||
}
|
||||
});
|
||||
|
||||
// Enable/disable unlock button based on password input
|
||||
passwordInput?.addEventListener('input', () => {
|
||||
unlockBtn.disabled = !passwordInput.value;
|
||||
});
|
||||
|
||||
// Handle enter key
|
||||
passwordInput?.addEventListener('keyup', (e) => {
|
||||
if (e.key === 'Enter' && passwordInput.value) {
|
||||
attemptUnlock();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle unlock button click
|
||||
unlockBtn?.addEventListener('click', attemptUnlock);
|
||||
|
||||
async function attemptUnlock() {
|
||||
if (!passwordInput?.value) return;
|
||||
|
||||
// Show deriving overlay
|
||||
derivingOverlay?.classList.remove('hidden');
|
||||
errorAlert?.classList.add('hidden');
|
||||
|
||||
const message: UnlockRequestMessage = {
|
||||
type: 'unlock-request',
|
||||
id,
|
||||
password: passwordInput.value,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await browser.runtime.sendMessage(message) as UnlockResponseMessage;
|
||||
|
||||
if (response.success) {
|
||||
// Success - close the window
|
||||
window.close();
|
||||
} else {
|
||||
// Failed - show error
|
||||
derivingOverlay?.classList.add('hidden');
|
||||
showError(response.error || 'Invalid password');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to send unlock message:', error);
|
||||
derivingOverlay?.classList.add('hidden');
|
||||
showError('Failed to unlock vault');
|
||||
}
|
||||
}
|
||||
|
||||
function showError(message: string) {
|
||||
if (errorAlert && errorMessage) {
|
||||
errorMessage.innerText = message;
|
||||
errorAlert.classList.remove('hidden');
|
||||
setTimeout(() => {
|
||||
errorAlert.classList.add('hidden');
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Focus password input on load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
passwordInput?.focus();
|
||||
});
|
||||
@@ -12,7 +12,8 @@
|
||||
"src/plebian-signer-extension.ts",
|
||||
"src/plebian-signer-content-script.ts",
|
||||
"src/prompt.ts",
|
||||
"src/options.ts"
|
||||
"src/options.ts",
|
||||
"src/unlock.ts"
|
||||
],
|
||||
"include": ["src/**/*.d.ts"]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { StorageService } from '../services/storage/storage.service';
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
declare const chrome: {
|
||||
windows: {
|
||||
create: (options: {
|
||||
type: string;
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
left: number;
|
||||
top: number;
|
||||
}) => void;
|
||||
};
|
||||
};
|
||||
|
||||
export class NavComponent {
|
||||
readonly #router = inject(Router);
|
||||
protected readonly storage = inject(StorageService);
|
||||
devMode = false;
|
||||
|
||||
constructor() {
|
||||
this.devMode = this.storage.getSignerMetaHandler().signerMetaData?.devMode ?? false;
|
||||
}
|
||||
|
||||
navigateBack() {
|
||||
window.history.back();
|
||||
@@ -11,4 +32,32 @@ export class NavComponent {
|
||||
navigate(path: string) {
|
||||
this.#router.navigate([path]);
|
||||
}
|
||||
|
||||
onTestPrompt() {
|
||||
const testEvent = {
|
||||
kind: 1,
|
||||
content: 'This is a test note for permission prompt preview.',
|
||||
tags: [],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
const base64Event = Buffer.from(JSON.stringify(testEvent, null, 2)).toString('base64');
|
||||
const currentIdentity = this.storage.getBrowserSessionHandler().browserSessionData?.identities.find(
|
||||
i => i.id === this.storage.getBrowserSessionHandler().browserSessionData?.selectedIdentityId
|
||||
);
|
||||
const nick = currentIdentity?.nick ?? 'Test Identity';
|
||||
|
||||
const width = 375;
|
||||
const height = 600;
|
||||
const left = Math.round((screen.width - width) / 2);
|
||||
const top = Math.round((screen.height - height) / 2);
|
||||
|
||||
chrome.windows.create({
|
||||
type: 'popup',
|
||||
url: `prompt.html?method=signEvent&host=example.com&id=test-${Date.now()}&nick=${encodeURIComponent(nick)}&event=${base64Event}`,
|
||||
width,
|
||||
height,
|
||||
left,
|
||||
top,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -14,6 +14,7 @@ describe('IconButtonComponent', () => {
|
||||
|
||||
fixture = TestBed.createComponent(IconButtonComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.icon = 'settings'; // Required input
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ describe('PubkeyComponent', () => {
|
||||
|
||||
fixture = TestBed.createComponent(PubkeyComponent);
|
||||
component = fixture.componentInstance;
|
||||
// Valid test pubkey (64 hex chars)
|
||||
component.value = 'a'.repeat(64);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
|
||||