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>
This commit is contained in:
woikos
2026-01-06 17:30:42 +01:00
parent 5183a4fc0a
commit 58e9053867
23 changed files with 747 additions and 424 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "plebeian-signer",
"version": "v1.0.8",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "plebeian-signer",
"version": "v1.0.8",
"version": "1.2.0",
"dependencies": {
"@angular/animations": "^19.0.0",
"@angular/common": "^19.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "plebeian-signer",
"version": "1.1.6",
"version": "1.2.0",
"custom": {
"chrome": {
"version": "v1.1.6"

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

@@ -1,32 +1,120 @@
<div class="vertically-centered">
<div class="sam-flex-column center">
<div class="sam-flex-column gap" style="align-items: center">
<span class="title">Plebeian Signer</span>
<div class="container">
<div class="logo-section">
<div class="logo-frame">
<img src="logo.svg" height="80" width="80" alt="" />
</div>
<span class="title">Plebeian Signer</span>
</div>
<div class="logo-frame">
<img src="logo.svg" height="120" width="120" alt="" />
</div>
<!-- New Identity Section -->
<div class="section">
<h2 class="section-heading">Restore or Create New Identity</h2>
<span class="section-note">Create a new nostr identity or paste in your current nsec.</span>
<input
type="text"
class="form-control"
placeholder="nickname"
[(ngModel)]="nickname"
/>
<div class="input-group">
<input
#nsecInputElement
type="password"
class="form-control"
placeholder="nsec or hex private key"
[(ngModel)]="nsecInput"
(ngModelChange)="validateNsec()"
/>
<button
class="btn btn-outline-secondary"
type="button"
(click)="toggleVisibility(nsecInputElement)"
title="toggle visibility"
>
<i
class="bi"
[class.bi-eye]="nsecInputElement.type === 'password'"
[class.bi-eye-slash]="nsecInputElement.type === 'text'"
></i>
</button>
<button
class="btn btn-outline-secondary"
type="button"
(click)="copyToClipboard()"
title="copy to clipboard"
>
<i class="bi bi-clipboard"></i>
</button>
</div>
<div class="button-row">
<button
type="button"
class="sam-mt-2 btn btn-primary"
(click)="router.navigateByUrl('/vault-create/new')"
class="btn btn-outline-secondary generate-btn"
(click)="generateKey()"
title="generate new key"
>
<div class="sam-flex-row gap-h">
<i class="bi bi-plus-circle" style="height: 22px"></i>
<span>Create a new vault</span>
</div>
<span>generate</span>
<span></span>
</button>
<span class="sam-text-muted">or</span>
<button
type="button"
class="btn btn-secondary"
(click)="router.navigateByUrl('/vault-import')"
class="btn btn-primary continue-btn"
[disabled]="!isNsecValid || !nickname"
(click)="onContinueWithNsec()"
>
<span>Import a vault</span>
<span>Continue</span>
<i class="bi bi-arrow-right"></i>
</button>
</div>
</div>
<!-- Import Section -->
<div class="section">
<h2 class="section-heading">Import a Vault</h2>
<input
#fileInput
type="file"
class="file-input"
accept=".json"
(change)="onFileSelected($event)"
/>
<div class="import-controls">
<button
type="button"
class="btn btn-outline-secondary file-btn"
(click)="fileInput.click()"
>
<i class="bi bi-folder2-open"></i>
<span>Add vault file</span>
</button>
@if (snapshots.length > 0) {
<div class="import-row">
<select class="form-select" [(ngModel)]="selectedSnapshot">
@for (snapshot of snapshots; track snapshot.id) {
<option [ngValue]="snapshot">
{{ snapshot.fileName }} ({{ snapshot.identityCount }} identities)
</option>
}
</select>
<button
type="button"
class="btn btn-primary icon-btn"
[disabled]="!selectedSnapshot"
(click)="onImport()"
title="import vault"
>
<i class="bi bi-arrow-right"></i>
</button>
</div>
}
</div>
</div>
</div>

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

@@ -1,32 +1,120 @@
<div class="vertically-centered">
<div class="sam-flex-column center">
<div class="sam-flex-column gap" style="align-items: center">
<span class="title">Plebeian Signer</span>
<div class="container">
<div class="logo-section">
<div class="logo-frame">
<img src="logo.svg" height="80" width="80" alt="" />
</div>
<span class="title">Plebeian Signer</span>
</div>
<div class="logo-frame">
<img src="logo.svg" height="120" width="120" alt="" />
</div>
<!-- New Identity Section -->
<div class="section">
<h2 class="section-heading">Restore or Create New Identity</h2>
<span class="section-note">Create a new nostr identity or paste in your current nsec.</span>
<input
type="text"
class="form-control"
placeholder="nickname"
[(ngModel)]="nickname"
/>
<div class="input-group">
<input
#nsecInputElement
type="password"
class="form-control"
placeholder="nsec or hex private key"
[(ngModel)]="nsecInput"
(ngModelChange)="validateNsec()"
/>
<button
class="btn btn-outline-secondary"
type="button"
(click)="toggleVisibility(nsecInputElement)"
title="toggle visibility"
>
<i
class="bi"
[class.bi-eye]="nsecInputElement.type === 'password'"
[class.bi-eye-slash]="nsecInputElement.type === 'text'"
></i>
</button>
<button
class="btn btn-outline-secondary"
type="button"
(click)="copyToClipboard()"
title="copy to clipboard"
>
<i class="bi bi-clipboard"></i>
</button>
</div>
<div class="button-row">
<button
type="button"
class="sam-mt-2 btn btn-primary"
(click)="router.navigateByUrl('/vault-create/new')"
class="btn btn-outline-secondary generate-btn"
(click)="generateKey()"
title="generate new key"
>
<div class="sam-flex-row gap-h">
<i class="bi bi-plus-circle" style="height: 22px"></i>
<span>Create a new vault</span>
</div>
<span>generate</span>
<span></span>
</button>
<span class="sam-text-muted">or</span>
<button
type="button"
class="btn btn-secondary"
(click)="router.navigateByUrl('/vault-import')"
class="btn btn-primary continue-btn"
[disabled]="!isNsecValid || !nickname"
(click)="onContinueWithNsec()"
>
<span>Import a vault</span>
<span>Continue</span>
<i class="bi bi-arrow-right"></i>
</button>
</div>
</div>
</div>
<!-- Import Section -->
<div class="section">
<h2 class="section-heading">Import a Vault</h2>
<input
#fileInput
type="file"
class="file-input"
accept=".json"
(change)="onFileSelected($event)"
/>
<div class="import-controls">
<button
type="button"
class="btn btn-outline-secondary file-btn"
(click)="fileInput.click()"
>
<i class="bi bi-folder2-open"></i>
<span>Add vault file</span>
</button>
@if (snapshots.length > 0) {
<div class="import-row">
<select class="form-select" [(ngModel)]="selectedSnapshot">
@for (snapshot of snapshots; track snapshot.id) {
<option [ngValue]="snapshot">
{{ snapshot.fileName }} ({{ snapshot.identityCount }} identities)
</option>
}
</select>
<button
type="button"
class="btn btn-primary icon-btn"
[disabled]="!selectedSnapshot"
(click)="onImport()"
title="import vault"
>
<i class="bi bi-arrow-right"></i>
</button>
</div>
}
</div>
</div>
</div>

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