6 Commits
v1.1.5 ... main

Author SHA1 Message Date
woikos
482356a9e4 Release v1.2.2 - Simplify Cashu onboarding
- Remove storage info page, replace with simple backup reminder
- Add "Have you set up backups?" link to Configure Backups in settings
- Increase component style budget to 30kB

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 18:17:30 +01:00
woikos
a90eafbf18 Release v1.2.1 - Add quick-add mint list to Cashu wallet
- Add suggested mints list (Minibits, Coinos, 21Mint, Macadamia, Stablenut)
- Show quick-add menu on empty Cashu page with + icon and descriptions
- Add collapsible "Quick Add" disclosure when mints exist
- Hide already-added mints from the list
- Closes #6

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 18:00:48 +01:00
woikos
58e9053867 Release v1.2.0 - Streamlined vault creation flow
- Remove sync preference welcome page, default to no-sync
- Redesign vault-create home with nickname + nsec input
- Add generate key button, visibility toggle, clipboard copy
- Add vault file import with persistent snapshot list
- Navigate to profile view after identity creation
- Fix router state access for identity data passing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 17:30:42 +01:00
woikos
5183a4fc0a Add Unlicense (public domain)
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 13:05:26 +01:00
woikos
a2d0a9bd32 Add privacy policy for extension store submissions
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 10:45:39 +01:00
woikos
5cf0fed4ed Add store screenshots and descriptions for Chrome/Firefox
- Chrome Web Store screenshots (1280x800)
- Firefox AMO screenshots (1280x800)
- Store listing descriptions for both platforms
- Source screenshots from extension UI

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 10:31:24 +01:00
48 changed files with 1612 additions and 549 deletions

24
LICENSE Normal file
View 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
View 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

View File

@@ -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
View File

@@ -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",

View File

@@ -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"
}

Binary file not shown.

Binary file not shown.

View 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": [

View File

@@ -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,

View File

@@ -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

View File

@@ -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));
}
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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,
});
}
}

View File

@@ -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);

View File

@@ -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>

View File

@@ -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);
}
}
}
}

View File

@@ -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();
});
});

View File

@@ -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');
}
}

View File

@@ -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);

View File

@@ -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": []
}
}
}
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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));
}
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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,
});
}
}

View File

@@ -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);

View File

@@ -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>

View File

@@ -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);
}
}
}
}

View File

@@ -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();
});
});

View File

@@ -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');
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

BIN
screenshots/chrome-sign.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 771 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

View 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