Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
482356a9e4 | ||
|
|
a90eafbf18 | ||
|
|
58e9053867 | ||
|
|
5183a4fc0a | ||
|
|
a2d0a9bd32 | ||
|
|
5cf0fed4ed |
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
|
||||
@@ -51,8 +51,8 @@
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "20kB",
|
||||
"maximumError": "25kB"
|
||||
"maximumWarning": "25kB",
|
||||
"maximumError": "30kB"
|
||||
}
|
||||
],
|
||||
"optimization": {
|
||||
@@ -154,8 +154,8 @@
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "20kB",
|
||||
"maximumError": "25kB"
|
||||
"maximumWarning": "25kB",
|
||||
"maximumError": "30kB"
|
||||
}
|
||||
],
|
||||
"optimization": {
|
||||
|
||||
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "plebeian-signer",
|
||||
"version": "v1.0.8",
|
||||
"version": "1.2.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "plebeian-signer",
|
||||
"version": "v1.0.8",
|
||||
"version": "1.2.2",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^19.0.0",
|
||||
"@angular/common": "^19.0.0",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "plebeian-signer",
|
||||
"version": "v1.1.5",
|
||||
"version": "1.2.2",
|
||||
"custom": {
|
||||
"chrome": {
|
||||
"version": "v1.1.5"
|
||||
"version": "v1.1.6"
|
||||
},
|
||||
"firefox": {
|
||||
"version": "v1.1.5"
|
||||
"version": "v1.1.6"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@@ -74,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
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 3,
|
||||
"name": "Plebeian Signer - Nostr Identity Manager & Signer",
|
||||
"description": "Manage and switch between multiple identities while interacting with Nostr apps",
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.6",
|
||||
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
|
||||
"options_page": "options.html",
|
||||
"permissions": [
|
||||
|
||||
@@ -4,7 +4,6 @@ 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';
|
||||
@@ -25,10 +24,6 @@ import { WhitelistedAppsComponent } from './components/whitelisted-apps/whitelis
|
||||
import { ProfileEditComponent } from './components/profile-edit/profile-edit.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: 'welcome',
|
||||
component: WelcomeComponent,
|
||||
},
|
||||
{
|
||||
path: 'vault-login',
|
||||
component: VaultLoginComponent,
|
||||
|
||||
@@ -57,73 +57,41 @@
|
||||
<div class="lightning-section">
|
||||
@if (mints.length === 0) {
|
||||
<div class="cashu-onboarding">
|
||||
@if (showCashuInfo) {
|
||||
<div class="info-panel">
|
||||
<h3>Welcome to Cashu Wallet</h3>
|
||||
|
||||
<div class="info-section">
|
||||
<h4>Storage Considerations</h4>
|
||||
@if (currentSyncFlow === BrowserSyncFlow.BROWSER_SYNC) {
|
||||
<div class="warning-box">
|
||||
<p><strong>Browser Sync is enabled</strong></p>
|
||||
<p>
|
||||
Sync storage is limited to ~100KB shared across all your vault data
|
||||
(identities, permissions, relays, and Cashu tokens). This limits
|
||||
your Cashu wallet to approximately 300-400 tokens.
|
||||
</p>
|
||||
<p>
|
||||
For larger Cashu holdings, consider disabling browser sync which
|
||||
provides ~5MB of local storage (~18,000+ tokens).
|
||||
</p>
|
||||
<button class="link-btn" (click)="navigateToSettings()">
|
||||
Change Sync Settings
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="success-box">
|
||||
<p><strong>Local Storage Mode</strong></p>
|
||||
<p>
|
||||
You have ~5MB of local storage available, which can hold
|
||||
thousands of Cashu tokens. Your data stays on this device only.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<h4>Backup Your Wallet</h4>
|
||||
<p>
|
||||
<strong>Important:</strong> Cashu tokens are bearer assets.
|
||||
If you lose your vault backup, you lose your tokens permanently.
|
||||
</p>
|
||||
<p>
|
||||
Vault exports are saved to your browser's downloads folder.
|
||||
Configure this to point to either:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Your backup storage device (external drive, NAS)</li>
|
||||
<li>A folder synced by your backup tool (Syncthing, rsync, etc.)</li>
|
||||
</ul>
|
||||
<p class="browser-url">
|
||||
<code>{{ browserDownloadSettingsUrl }}</code>
|
||||
</p>
|
||||
<button class="link-btn" (click)="navigateToSettings()">
|
||||
Go to Backup Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="dismiss-btn" (click)="dismissCashuInfo()">
|
||||
Got it, let me add a mint
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="empty-state">
|
||||
<span class="sam-text-muted">No mints connected yet.</span>
|
||||
<button class="show-info-btn" (click)="showCashuInfo = true">
|
||||
Show storage info
|
||||
|
||||
<!-- 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">
|
||||
@@ -134,6 +102,33 @@
|
||||
</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>
|
||||
@@ -210,6 +205,31 @@
|
||||
<!-- 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
|
||||
|
||||
@@ -1134,3 +1134,198 @@
|
||||
padding: var(--size);
|
||||
}
|
||||
}
|
||||
|
||||
// Suggested mints quick-add
|
||||
.suggested-mints {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-h);
|
||||
|
||||
&.centered {
|
||||
align-items: center;
|
||||
margin-top: var(--size);
|
||||
|
||||
.suggested-list {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.suggested-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.suggested-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--size-h);
|
||||
}
|
||||
|
||||
.suggested-mint-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
background: var(--background-light);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
color: var(--foreground);
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--background-light-hover);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.already-added {
|
||||
opacity: 0.5;
|
||||
background: transparent;
|
||||
|
||||
.added-badge {
|
||||
color: var(--success, #22c55e);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.mint-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size);
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.75rem;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
// Quick add section (empty state)
|
||||
.quick-add-section {
|
||||
width: 100%;
|
||||
margin-top: var(--size);
|
||||
|
||||
.quick-add-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
margin-bottom: var(--size-h);
|
||||
}
|
||||
}
|
||||
|
||||
// Backup reminder
|
||||
.backup-reminder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--size-h);
|
||||
margin-top: var(--size);
|
||||
padding-top: var(--size);
|
||||
border-top: 1px solid var(--border);
|
||||
|
||||
span {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
// Quick add disclosure (when mints exist)
|
||||
.quick-add-disclosure {
|
||||
margin-top: var(--size-h);
|
||||
|
||||
summary {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
padding: var(--size-h);
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&::marker {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&[open] summary {
|
||||
margin-bottom: var(--size-h);
|
||||
}
|
||||
}
|
||||
|
||||
// Quick add menu (shared by both)
|
||||
.quick-add-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
background: var(--background-light);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quick-add-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
padding: var(--size-h) var(--size);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--background-light-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.mint-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size-h);
|
||||
}
|
||||
|
||||
.add-icon {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--success, #22c55e);
|
||||
}
|
||||
|
||||
.mint-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.mint-desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
padding-left: calc(1rem + var(--size-h));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,15 @@ export class WalletComponent extends NavComponent implements OnInit, OnDestroy {
|
||||
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':
|
||||
@@ -499,6 +508,35 @@ export class WalletComponent extends NavComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -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="120" width="120" alt="" />
|
||||
<img src="logo.svg" height="80" width="80" alt="" />
|
||||
</div>
|
||||
<span class="title">Plebeian Signer</span>
|
||||
</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="btn btn-outline-secondary generate-btn"
|
||||
(click)="generateKey()"
|
||||
title="generate new key"
|
||||
>
|
||||
<span>generate</span>
|
||||
<span>✨</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="sam-mt-2 btn btn-primary"
|
||||
(click)="router.navigateByUrl('/vault-create/new')"
|
||||
class="btn btn-primary continue-btn"
|
||||
[disabled]="!isNsecValid || !nickname"
|
||||
(click)="onContinueWithNsec()"
|
||||
>
|
||||
<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>Continue</span>
|
||||
<i class="bi bi-arrow-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="sam-text-muted">or</span>
|
||||
<!-- 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-secondary"
|
||||
(click)="router.navigateByUrl('/vault-import')"
|
||||
class="btn btn-outline-secondary file-btn"
|
||||
(click)="fileInput.click()"
|
||||
>
|
||||
<span>Import a vault</span>
|
||||
<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,7 +1,12 @@
|
||||
import { Component, inject, ViewChild } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { LoggerService, NavComponent, StorageService, DerivingModalComponent } from '@common';
|
||||
import {
|
||||
LoggerService,
|
||||
NavComponent,
|
||||
StorageService,
|
||||
DerivingModalComponent,
|
||||
} from '@common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-new',
|
||||
@@ -18,6 +23,15 @@ export class NewComponent extends NavComponent {
|
||||
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') {
|
||||
element.type = 'text';
|
||||
@@ -35,9 +49,22 @@ export class NewComponent extends NavComponent {
|
||||
this.derivingModal.show('Creating secure vault');
|
||||
try {
|
||||
await this.#storage.createNewVault(this.password);
|
||||
this.derivingModal.hide();
|
||||
this.#logger.logVaultCreated();
|
||||
this.#router.navigateByUrl('/home/identities');
|
||||
|
||||
// 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,64 +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="storage-info">
|
||||
<details>
|
||||
<summary>Important for Cashu wallet users</summary>
|
||||
<p>
|
||||
Browser sync storage is limited to ~100KB shared across all data
|
||||
(identities, permissions, relays, and Cashu tokens).
|
||||
</p>
|
||||
<p>
|
||||
If you plan to use the Cashu ecash wallet with significant balances,
|
||||
choose <strong>"Sync OFF"</strong> which provides ~5MB of local storage
|
||||
(enough for ~18,000+ tokens vs ~300-400 with sync).
|
||||
</p>
|
||||
<p>
|
||||
<strong>Note:</strong> Cashu tokens are bearer assets. If you lose your
|
||||
vault backup, you lose your tokens permanently. Make sure to configure
|
||||
regular backups.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<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,46 +0,0 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
}
|
||||
|
||||
.storage-info {
|
||||
margin-top: 1rem;
|
||||
width: 100%;
|
||||
|
||||
details {
|
||||
background: rgba(255, 193, 7, 0.1);
|
||||
border: 1px solid var(--warning, #ffc107);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
color: var(--warning, #ffc107);
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.75rem 0 0 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
color: var(--text-muted, #6c757d);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--text, #212529);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
StorageService,
|
||||
StorageServiceConfig,
|
||||
} from '../storage/storage.service';
|
||||
import { SyncFlow } from '../storage/types';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@@ -25,8 +26,9 @@ export class StartupService {
|
||||
// Step 1: Load the user settings
|
||||
const signerMetaData = await this.#storage.loadSignerMetaData();
|
||||
if (typeof signerMetaData?.syncFlow === 'undefined') {
|
||||
// Very first run. The user has not set up Plebeian Signer yet.
|
||||
this.#router.navigateByUrl('/welcome');
|
||||
// Very first run - default to NO_SYNC (sync can be enabled later via export/import)
|
||||
await this.#storage.enableBrowserSyncFlow(SyncFlow.NO_SYNC);
|
||||
this.#router.navigateByUrl('/vault-create/home');
|
||||
return;
|
||||
}
|
||||
this.#storage.enableBrowserSyncFlow(signerMetaData.syncFlow);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 3,
|
||||
"name": "Plebeian Signer",
|
||||
"description": "Nostr Identity Manager & Signer",
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.6",
|
||||
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
|
||||
"options_page": "options.html",
|
||||
"permissions": [
|
||||
@@ -51,7 +51,13 @@
|
||||
],
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "plebian-signer@mleku.dev"
|
||||
"id": "plebian-signer@mleku.dev",
|
||||
"data_collection_permissions": {
|
||||
"required": [
|
||||
"none"
|
||||
],
|
||||
"optional": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import { KeysComponent as EditIdentityKeysComponent } from './components/edit-id
|
||||
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 { WelcomeComponent } from './components/welcome/welcome.component';
|
||||
import { VaultLoginComponent } from './components/vault-login/vault-login.component';
|
||||
import { VaultCreateComponent } from './components/vault-create/vault-create.component';
|
||||
import { VaultImportComponent } from './components/vault-import/vault-import.component';
|
||||
@@ -25,10 +24,6 @@ import { WhitelistedAppsComponent } from './components/whitelisted-apps/whitelis
|
||||
import { ProfileEditComponent } from './components/profile-edit/profile-edit.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: 'welcome',
|
||||
component: WelcomeComponent,
|
||||
},
|
||||
{
|
||||
path: 'vault-login',
|
||||
component: VaultLoginComponent,
|
||||
|
||||
@@ -57,73 +57,41 @@
|
||||
<div class="lightning-section">
|
||||
@if (mints.length === 0) {
|
||||
<div class="cashu-onboarding">
|
||||
@if (showCashuInfo) {
|
||||
<div class="info-panel">
|
||||
<h3>Welcome to Cashu Wallet</h3>
|
||||
|
||||
<div class="info-section">
|
||||
<h4>Storage Considerations</h4>
|
||||
@if (currentSyncFlow === BrowserSyncFlow.BROWSER_SYNC) {
|
||||
<div class="warning-box">
|
||||
<p><strong>Browser Sync is enabled</strong></p>
|
||||
<p>
|
||||
Sync storage is limited to ~100KB shared across all your vault data
|
||||
(identities, permissions, relays, and Cashu tokens). This limits
|
||||
your Cashu wallet to approximately 300-400 tokens.
|
||||
</p>
|
||||
<p>
|
||||
For larger Cashu holdings, consider disabling browser sync which
|
||||
provides ~5MB of local storage (~18,000+ tokens).
|
||||
</p>
|
||||
<button class="link-btn" (click)="navigateToSettings()">
|
||||
Change Sync Settings
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="success-box">
|
||||
<p><strong>Local Storage Mode</strong></p>
|
||||
<p>
|
||||
You have ~5MB of local storage available, which can hold
|
||||
thousands of Cashu tokens. Your data stays on this device only.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<h4>Backup Your Wallet</h4>
|
||||
<p>
|
||||
<strong>Important:</strong> Cashu tokens are bearer assets.
|
||||
If you lose your vault backup, you lose your tokens permanently.
|
||||
</p>
|
||||
<p>
|
||||
Vault exports are saved to your browser's downloads folder.
|
||||
Configure this to point to either:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Your backup storage device (external drive, NAS)</li>
|
||||
<li>A folder synced by your backup tool (Syncthing, rsync, etc.)</li>
|
||||
</ul>
|
||||
<p class="browser-url">
|
||||
<code>{{ browserDownloadSettingsUrl }}</code>
|
||||
</p>
|
||||
<button class="link-btn" (click)="navigateToSettings()">
|
||||
Go to Backup Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="dismiss-btn" (click)="dismissCashuInfo()">
|
||||
Got it, let me add a mint
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="empty-state">
|
||||
<span class="sam-text-muted">No mints connected yet.</span>
|
||||
<button class="show-info-btn" (click)="showCashuInfo = true">
|
||||
Show storage info
|
||||
|
||||
<!-- 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">
|
||||
@@ -134,6 +102,33 @@
|
||||
</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>
|
||||
@@ -210,6 +205,31 @@
|
||||
<!-- 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
|
||||
|
||||
@@ -1134,3 +1134,189 @@
|
||||
padding: var(--size);
|
||||
}
|
||||
}
|
||||
|
||||
// Suggested mints quick-add
|
||||
.suggested-mints {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-h);
|
||||
|
||||
.suggested-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.suggested-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--size-h);
|
||||
}
|
||||
|
||||
.suggested-mint-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
background: var(--background-light);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
color: var(--foreground);
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--background-light-hover);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.already-added {
|
||||
opacity: 0.5;
|
||||
background: transparent;
|
||||
|
||||
.added-badge {
|
||||
color: var(--success, #22c55e);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.mint-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size);
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.75rem;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
// Quick add section (empty state)
|
||||
.quick-add-section {
|
||||
width: 100%;
|
||||
margin-top: var(--size);
|
||||
|
||||
.quick-add-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
margin-bottom: var(--size-h);
|
||||
}
|
||||
}
|
||||
|
||||
// Backup reminder
|
||||
.backup-reminder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--size-h);
|
||||
margin-top: var(--size);
|
||||
padding-top: var(--size);
|
||||
border-top: 1px solid var(--border);
|
||||
|
||||
span {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
// Quick add disclosure (when mints exist)
|
||||
.quick-add-disclosure {
|
||||
margin-top: var(--size-h);
|
||||
|
||||
summary {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
padding: var(--size-h);
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&::marker {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&[open] summary {
|
||||
margin-bottom: var(--size-h);
|
||||
}
|
||||
}
|
||||
|
||||
// Quick add menu (shared by both)
|
||||
.quick-add-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
background: var(--background-light);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quick-add-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
padding: var(--size-h) var(--size);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--background-light-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.mint-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size-h);
|
||||
}
|
||||
|
||||
.add-icon {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--success, #22c55e);
|
||||
}
|
||||
|
||||
.mint-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.mint-desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
padding-left: calc(1rem + var(--size-h));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,15 @@ export class WalletComponent extends NavComponent implements OnInit, OnDestroy {
|
||||
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':
|
||||
@@ -499,6 +508,35 @@ export class WalletComponent extends NavComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -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="120" width="120" alt="" />
|
||||
<img src="logo.svg" height="80" width="80" alt="" />
|
||||
</div>
|
||||
<span class="title">Plebeian Signer</span>
|
||||
</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="btn btn-outline-secondary generate-btn"
|
||||
(click)="generateKey()"
|
||||
title="generate new key"
|
||||
>
|
||||
<span>generate</span>
|
||||
<span>✨</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="sam-mt-2 btn btn-primary"
|
||||
(click)="router.navigateByUrl('/vault-create/new')"
|
||||
class="btn btn-primary continue-btn"
|
||||
[disabled]="!isNsecValid || !nickname"
|
||||
(click)="onContinueWithNsec()"
|
||||
>
|
||||
<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>Continue</span>
|
||||
<i class="bi bi-arrow-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="sam-text-muted">or</span>
|
||||
<!-- 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-secondary"
|
||||
(click)="router.navigateByUrl('/vault-import')"
|
||||
class="btn btn-outline-secondary file-btn"
|
||||
(click)="fileInput.click()"
|
||||
>
|
||||
<span>Import a vault</span>
|
||||
<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,12 +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,
|
||||
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-vault-create-home',
|
||||
imports: [],
|
||||
imports: [FormsModule],
|
||||
templateUrl: './home.component.html',
|
||||
styleUrl: './home.component.scss',
|
||||
})
|
||||
export class HomeComponent {
|
||||
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,7 +1,12 @@
|
||||
import { Component, inject, ViewChild } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { LoggerService, NavComponent, StorageService, DerivingModalComponent } from '@common';
|
||||
import {
|
||||
LoggerService,
|
||||
NavComponent,
|
||||
StorageService,
|
||||
DerivingModalComponent,
|
||||
} from '@common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-new',
|
||||
@@ -18,6 +23,15 @@ export class NewComponent extends NavComponent {
|
||||
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') {
|
||||
element.type = 'text';
|
||||
@@ -35,9 +49,22 @@ export class NewComponent extends NavComponent {
|
||||
this.derivingModal.show('Creating secure vault');
|
||||
try {
|
||||
await this.#storage.createNewVault(this.password);
|
||||
this.derivingModal.hide();
|
||||
this.#logger.logVaultCreated();
|
||||
this.#router.navigateByUrl('/home/identities');
|
||||
|
||||
// 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,64 +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 : Mozilla Firefox</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="storage-info">
|
||||
<details>
|
||||
<summary>Important for Cashu wallet users</summary>
|
||||
<p>
|
||||
Browser sync storage is limited to ~100KB shared across all data
|
||||
(identities, permissions, relays, and Cashu tokens).
|
||||
</p>
|
||||
<p>
|
||||
If you plan to use the Cashu ecash wallet with significant balances,
|
||||
choose <strong>"Sync OFF"</strong> which provides ~5MB of local storage
|
||||
(enough for ~18,000+ tokens vs ~300-400 with sync).
|
||||
</p>
|
||||
<p>
|
||||
<strong>Note:</strong> Cashu tokens are bearer assets. If you lose your
|
||||
vault backup, you lose your tokens permanently. Make sure to configure
|
||||
regular backups.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<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,46 +0,0 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
}
|
||||
|
||||
.storage-info {
|
||||
margin-top: 1rem;
|
||||
width: 100%;
|
||||
|
||||
details {
|
||||
background: rgba(255, 193, 7, 0.1);
|
||||
border: 1px solid var(--warning, #ffc107);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
color: var(--warning, #ffc107);
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.75rem 0 0 0;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
color: var(--text-muted, #6c757d);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--text, #212529);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
BIN
screenshots/chrome-cashu.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
screenshots/chrome-profile.png
Normal file
|
After Width: | Height: | Size: 242 KiB |
BIN
screenshots/chrome-sign.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
screenshots/firefox-cashu.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
screenshots/firefox-profile.png
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
screenshots/firefox-sign.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
screenshots/store-firefox/01-identity-management.png
Normal file
|
After Width: | Height: | Size: 771 KiB |
BIN
screenshots/store-firefox/02-cashu-wallet.png
Normal file
|
After Width: | Height: | Size: 254 KiB |
BIN
screenshots/store-firefox/03-signing-permissions.png
Normal file
|
After Width: | Height: | Size: 335 KiB |
77
screenshots/store-firefox/AMO_DESCRIPTION.txt
Normal file
@@ -0,0 +1,77 @@
|
||||
MOZILLA ADD-ONS (AMO) LISTING
|
||||
=============================
|
||||
|
||||
NAME: Plebeian Signer
|
||||
|
||||
SUMMARY (250 chars max):
|
||||
Manage multiple Nostr identities and sign events securely. Built-in Cashu ecash wallet for sending and receiving sats. Your private keys never leave the extension.
|
||||
|
||||
DESCRIPTION:
|
||||
------------
|
||||
|
||||
Plebeian Signer is a secure browser extension for managing your Nostr identities without exposing your private keys to web applications.
|
||||
|
||||
<b>Key Features</b>
|
||||
|
||||
<b>Multiple Identity Management</b>
|
||||
• Create and manage multiple Nostr identities in one place
|
||||
• Switch between profiles instantly when using Nostr apps
|
||||
• Import existing keys or generate new ones
|
||||
• Customize profiles with display names and metadata
|
||||
|
||||
<b>Secure Key Storage</b>
|
||||
• Private keys encrypted with Argon2id + AES-256-GCM
|
||||
• Keys never leave the extension - apps only receive signatures
|
||||
• Password-protected vault with automatic locking
|
||||
• Optional sync across browser instances
|
||||
|
||||
<b>NIP-07 Signing</b>
|
||||
• Full NIP-07 implementation (window.nostr interface)
|
||||
• Review event details before signing
|
||||
• Granular permission controls per site and event kind
|
||||
• One-click approve or reject with "always" options
|
||||
• Supports NIP-04 and NIP-44 encryption/decryption
|
||||
|
||||
<b>Built-in Cashu Wallet</b>
|
||||
• Store and manage ecash (Cashu tokens)
|
||||
• Send and receive tokens instantly
|
||||
• Deposit sats via Lightning invoices
|
||||
• Connect to multiple Cashu mints
|
||||
• View token history and check for spent proofs
|
||||
|
||||
<b>Privacy Focused</b>
|
||||
• No data collection or analytics
|
||||
• No external connections except to mints you configure
|
||||
• Fully open source
|
||||
• Works offline for signing operations
|
||||
|
||||
<b>Supported Nostr Apps</b>
|
||||
Works with any NIP-07 compatible application including Snort, Iris, Coracle, Nostrudel, Habla, and many more.
|
||||
|
||||
---
|
||||
|
||||
CATEGORIES:
|
||||
- Primary: Social & Communication
|
||||
- Secondary: Privacy & Security
|
||||
|
||||
TAGS: nostr, signing, identity, wallet, cashu, ecash, lightning, bitcoin, privacy, encryption
|
||||
|
||||
LICENSE: MIT
|
||||
|
||||
HOMEPAGE: https://git.mleku.dev/mleku/plebeian-signer
|
||||
|
||||
SUPPORT EMAIL: (your email)
|
||||
|
||||
PRIVACY POLICY: This extension does not collect, store, or transmit any user data to external servers. All data is stored locally in your browser using encrypted storage. The only external connections made are to Cashu mints that you explicitly configure.
|
||||
|
||||
---
|
||||
|
||||
SCREENSHOTS (1280x800):
|
||||
1. 01-identity-management.png - Multiple identity management
|
||||
2. 02-cashu-wallet.png - Built-in Cashu ecash wallet
|
||||
3. 03-signing-permissions.png - Secure event signing permissions
|
||||
|
||||
---
|
||||
|
||||
EXTENSION ID: plebian-signer@mleku.dev
|
||||
VERSION: 1.1.5
|
||||
BIN
screenshots/store/01-identity-management.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
screenshots/store/02-cashu-wallet.png
Normal file
|
After Width: | Height: | Size: 298 KiB |
BIN
screenshots/store/03-signing-permissions.png
Normal file
|
After Width: | Height: | Size: 320 KiB |
73
screenshots/store/STORE_DESCRIPTION.txt
Normal file
@@ -0,0 +1,73 @@
|
||||
CHROME WEB STORE LISTING
|
||||
========================
|
||||
|
||||
NAME: Plebeian Signer - Nostr Identity Manager & Signer
|
||||
|
||||
SHORT DESCRIPTION (132 chars max):
|
||||
Manage multiple Nostr identities, sign events securely, and store ecash with the built-in Cashu wallet. Your keys, your control.
|
||||
|
||||
DETAILED DESCRIPTION:
|
||||
---------------------
|
||||
|
||||
Plebeian Signer is a secure browser extension for managing your Nostr identities without exposing your private keys to web applications.
|
||||
|
||||
KEY FEATURES
|
||||
|
||||
Multiple Identity Management
|
||||
• Create and manage multiple Nostr identities in one place
|
||||
• Switch between profiles instantly when using Nostr apps
|
||||
• Import existing keys or generate new ones
|
||||
• Customize profiles with display names and metadata
|
||||
|
||||
Secure Key Storage
|
||||
• Private keys are encrypted with Argon2id + AES-256-GCM
|
||||
• Keys never leave the extension - apps only receive signatures
|
||||
• Password-protected vault with automatic locking
|
||||
• Optional sync across browser instances
|
||||
|
||||
NIP-07 Signing
|
||||
• Full NIP-07 implementation (window.nostr interface)
|
||||
• Review event details before signing
|
||||
• Granular permission controls per site and event kind
|
||||
• One-click approve or reject with "always" options
|
||||
• Supports NIP-04 and NIP-44 encryption/decryption
|
||||
|
||||
Built-in Cashu Wallet
|
||||
• Store and manage ecash (Cashu tokens)
|
||||
• Send and receive tokens instantly
|
||||
• Deposit sats via Lightning invoices
|
||||
• Connect to multiple Cashu mints
|
||||
• View token history and check for spent proofs
|
||||
|
||||
Privacy Focused
|
||||
• No data collection or analytics
|
||||
• No external connections except to mints you configure
|
||||
• Fully open source
|
||||
• Works offline for signing operations
|
||||
|
||||
PERFECT FOR
|
||||
• Nostr users managing multiple personas
|
||||
• Privacy-conscious users who want key isolation
|
||||
• Anyone tired of copy-pasting private keys
|
||||
• Users who want ecash functionality integrated with their identity
|
||||
|
||||
SUPPORTED NOSTR APPS
|
||||
Works with any NIP-07 compatible application including Snort, Iris, Coracle, Nostrudel, Habla, and many more.
|
||||
|
||||
OPEN SOURCE
|
||||
Plebeian Signer is free and open source software. Review the code, report issues, or contribute at the project repository.
|
||||
|
||||
---
|
||||
|
||||
CATEGORY: Social & Communication
|
||||
|
||||
LANGUAGE: English
|
||||
|
||||
PRIVACY POLICY: Not required (no user data collected)
|
||||
|
||||
---
|
||||
|
||||
SCREENSHOTS (1280x800):
|
||||
1. 01-identity-management.png - Multiple identity management
|
||||
2. 02-cashu-wallet.png - Built-in Cashu ecash wallet
|
||||
3. 03-signing-permissions.png - Secure event signing permissions
|
||||