diff --git a/chrome_prepare_manifest.sh b/chrome_prepare_manifest.sh
index 7638c4c..e752a02 100755
--- a/chrome_prepare_manifest.sh
+++ b/chrome_prepare_manifest.sh
@@ -1,6 +1,7 @@
#!/bin/bash
-version=$( cat package.json | jq '.custom.chrome.version' | tr -d '"')
+# Extract version and strip 'v' prefix if present (manifest requires bare semver)
+version=$( cat package.json | jq -r '.custom.chrome.version' | sed 's/^v//')
jq '.version = $newVersion' --arg newVersion $version ./projects/chrome/public/manifest.json > ./projects/chrome/public/tmp.manifest.json && mv ./projects/chrome/public/tmp.manifest.json ./projects/chrome/public/manifest.json
diff --git a/firefox_prepare_manifest.sh b/firefox_prepare_manifest.sh
index 8d25799..ba190f1 100755
--- a/firefox_prepare_manifest.sh
+++ b/firefox_prepare_manifest.sh
@@ -1,6 +1,7 @@
#!/bin/bash
-version=$( cat package.json | jq '.custom.firefox.version' | tr -d '"')
+# Extract version and strip 'v' prefix if present (manifest requires bare semver)
+version=$( cat package.json | jq -r '.custom.firefox.version' | sed 's/^v//')
jq '.version = $newVersion' --arg newVersion $version ./projects/firefox/public/manifest.json > ./projects/firefox/public/tmp.manifest.json && mv ./projects/firefox/public/tmp.manifest.json ./projects/firefox/public/manifest.json
diff --git a/package.json b/package.json
index 9df26de..e519654 100644
--- a/package.json
+++ b/package.json
@@ -1,12 +1,12 @@
{
"name": "plebeian-signer",
- "version": "v0.0.8",
+ "version": "v0.0.9",
"custom": {
"chrome": {
- "version": "v0.0.8"
+ "version": "v0.0.9"
},
"firefox": {
- "version": "v0.0.8"
+ "version": "v0.0.9"
}
},
"scripts": {
diff --git a/projects/chrome/public/manifest.json b/projects/chrome/public/manifest.json
index 90c91e4..cb05eae 100644
--- a/projects/chrome/public/manifest.json
+++ b/projects/chrome/public/manifest.json
@@ -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": "v0.0.8",
+ "version": "0.0.9",
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
"options_page": "options.html",
"permissions": [
diff --git a/projects/chrome/public/person-fill.svg b/projects/chrome/public/person-fill.svg
index 660a118..443f3c7 100644
--- a/projects/chrome/public/person-fill.svg
+++ b/projects/chrome/public/person-fill.svg
@@ -1,3 +1,3 @@
-
\ No newline at end of file
+
diff --git a/projects/chrome/src/app/app.routes.ts b/projects/chrome/src/app/app.routes.ts
index 7256970..475e8b4 100644
--- a/projects/chrome/src/app/app.routes.ts
+++ b/projects/chrome/src/app/app.routes.ts
@@ -16,6 +16,7 @@ import { KeysComponent as EditIdentityKeysComponent } from './components/edit-id
import { PermissionsComponent as EditIdentityPermissionsComponent } from './components/edit-identity/permissions/permissions.component';
import { RelaysComponent as EditIdentityRelaysComponent } from './components/edit-identity/relays/relays.component';
import { VaultImportComponent } from './components/vault-import/vault-import.component';
+import { WhitelistedAppsComponent } from './components/whitelisted-apps/whitelisted-apps.component';
export const routes: Routes = [
{
@@ -70,6 +71,10 @@ export const routes: Routes = [
path: 'new-identity',
component: NewIdentityComponent,
},
+ {
+ path: 'whitelisted-apps',
+ component: WhitelistedAppsComponent,
+ },
{
path: 'edit-identity/:id',
component: EditIdentityComponent,
diff --git a/projects/chrome/src/app/components/home/identities/identities.component.html b/projects/chrome/src/app/components/home/identities/identities.component.html
index aae8b09..af3c483 100644
--- a/projects/chrome/src/app/components/home/identities/identities.component.html
+++ b/projects/chrome/src/app/components/home/identities/identities.component.html
@@ -11,6 +11,30 @@
+
+
+
+
+
@let sessionData = storage.getBrowserSessionHandler().browserSessionData;
@let identities = sessionData?.identities ?? [];
@@ -34,7 +58,7 @@
class="avatar"
[src]="getAvatarUrl(identity)"
alt=""
- (error)="$any($event.target).src = 'assets/person-fill.svg'"
+ (error)="$any($event.target).src = 'person-fill.svg'"
/>
{{ getDisplayName(identity) }}
profile for quick lookup
#profileCache = new Map();
+ get isRecklessMode(): boolean {
+ return this.storage.getSignerMetaHandler().signerMetaData?.recklessMode ?? false;
+ }
+
async ngOnInit() {
await this.#profileMetadata.initialize();
this.#loadProfiles();
@@ -40,7 +44,7 @@ export class IdentitiesComponent implements OnInit {
getAvatarUrl(identity: Identity_DECRYPTED): string {
const profile = this.#profileCache.get(identity.id);
- return profile?.picture || 'assets/person-fill.svg';
+ return profile?.picture || 'person-fill.svg';
}
getDisplayName(identity: Identity_DECRYPTED): string {
@@ -60,4 +64,13 @@ export class IdentitiesComponent implements OnInit {
async onClickSelectIdentity(identityId: string) {
await this.storage.switchIdentity(identityId);
}
+
+ async onToggleRecklessMode() {
+ const newValue = !this.isRecklessMode;
+ await this.storage.getSignerMetaHandler().setRecklessMode(newValue);
+ }
+
+ onClickWhitelistedApps() {
+ this.#router.navigateByUrl('/whitelisted-apps');
+ }
}
diff --git a/projects/chrome/src/app/components/whitelisted-apps/whitelisted-apps.component.html b/projects/chrome/src/app/components/whitelisted-apps/whitelisted-apps.component.html
new file mode 100644
index 0000000..1063baa
--- /dev/null
+++ b/projects/chrome/src/app/components/whitelisted-apps/whitelisted-apps.component.html
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+ @if (whitelistedHosts.length === 0) {
+
+ No whitelisted apps yet
+
+ @if (isRecklessMode) {
+
+ ⚠ All sites will be auto-approved without prompting
+
+ }
+ }
+
+ @for (host of whitelistedHosts; track host) {
+
+ {{ host }}
+
+
+ }
+
+
+
+
+
diff --git a/projects/chrome/src/app/components/whitelisted-apps/whitelisted-apps.component.scss b/projects/chrome/src/app/components/whitelisted-apps/whitelisted-apps.component.scss
new file mode 100644
index 0000000..73142fc
--- /dev/null
+++ b/projects/chrome/src/app/components/whitelisted-apps/whitelisted-apps.component.scss
@@ -0,0 +1,134 @@
+:host {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+
+ .custom-header {
+ padding: var(--size);
+ display: grid;
+ grid-template-columns: 1fr;
+ grid-template-rows: auto;
+ align-items: center;
+ background: var(--background);
+ position: sticky;
+ top: 0;
+ z-index: 10;
+
+ .back-btn {
+ grid-column-start: 1;
+ grid-column-end: 2;
+ grid-row-start: 1;
+ grid-row-end: 2;
+ justify-self: start;
+ background: transparent;
+ border: none;
+ color: var(--foreground);
+ padding: 4px 8px;
+ cursor: pointer;
+ border-radius: 4px;
+ z-index: 1;
+
+ &:hover {
+ background: var(--background-light);
+ }
+
+ i {
+ font-size: 20px;
+ }
+ }
+
+ .text {
+ grid-column-start: 1;
+ grid-column-end: 2;
+ grid-row-start: 1;
+ grid-row-end: 2;
+ font-family: var(--font-heading);
+ font-size: 20px;
+ font-weight: 700;
+ letter-spacing: 0.05rem;
+ justify-self: center;
+ }
+ }
+
+ .content {
+ padding: 0 var(--size) var(--size) var(--size);
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+
+ .whitelist-btn {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ margin-bottom: var(--size);
+ }
+
+ .hosts-list {
+ flex-grow: 1;
+
+ .empty-state {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 32px;
+ text-align: center;
+ }
+
+ .warning-note {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 12px;
+ background: rgba(255, 193, 7, 0.15);
+ border: 1px solid rgba(255, 193, 7, 0.4);
+ border-radius: 8px;
+ text-align: center;
+
+ span {
+ font-size: 13px;
+ color: #ffc107;
+ }
+ }
+
+ .host-item {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px;
+ background: var(--background-light);
+ border-radius: 8px;
+ margin-bottom: 8px;
+
+ .host-name {
+ font-size: 14px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .remove-btn {
+ background: transparent;
+ border: none;
+ color: var(--muted-foreground);
+ padding: 4px 8px;
+ cursor: pointer;
+ border-radius: 4px;
+ transition: color 0.15s ease, background-color 0.15s ease;
+
+ &:hover {
+ color: var(--destructive);
+ background: var(--background-light-hover);
+ }
+
+ i {
+ font-size: 14px;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/projects/chrome/src/app/components/whitelisted-apps/whitelisted-apps.component.ts b/projects/chrome/src/app/components/whitelisted-apps/whitelisted-apps.component.ts
new file mode 100644
index 0000000..61c994f
--- /dev/null
+++ b/projects/chrome/src/app/components/whitelisted-apps/whitelisted-apps.component.ts
@@ -0,0 +1,72 @@
+import { Component, inject, ViewChild } from '@angular/core';
+import { Router } from '@angular/router';
+import {
+ ConfirmComponent,
+ NavComponent,
+ StorageService,
+ ToastComponent,
+} from '@common';
+
+@Component({
+ selector: 'app-whitelisted-apps',
+ templateUrl: './whitelisted-apps.component.html',
+ styleUrl: './whitelisted-apps.component.scss',
+ imports: [ToastComponent, ConfirmComponent],
+})
+export class WhitelistedAppsComponent extends NavComponent {
+ @ViewChild('toast') toast!: ToastComponent;
+ @ViewChild('confirm') confirm!: ConfirmComponent;
+
+ readonly storage = inject(StorageService);
+ readonly #router = inject(Router);
+
+ get whitelistedHosts(): string[] {
+ return this.storage.getSignerMetaHandler().signerMetaData?.whitelistedHosts ?? [];
+ }
+
+ get isRecklessMode(): boolean {
+ return this.storage.getSignerMetaHandler().signerMetaData?.recklessMode ?? false;
+ }
+
+ async onClickWhitelistCurrentTab() {
+ try {
+ // Get current active tab
+ const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
+ if (tabs.length === 0 || !tabs[0].url) {
+ this.toast.show('No active tab found');
+ return;
+ }
+
+ const url = new URL(tabs[0].url);
+ const host = url.host;
+
+ if (!host) {
+ this.toast.show('Cannot get host from current tab');
+ return;
+ }
+
+ // Check if already whitelisted
+ if (this.whitelistedHosts.includes(host)) {
+ this.toast.show(`${host} is already whitelisted`);
+ return;
+ }
+
+ await this.storage.getSignerMetaHandler().addWhitelistedHost(host);
+ this.toast.show(`Added ${host} to whitelist`);
+ } catch (error) {
+ console.error('Error getting current tab:', error);
+ this.toast.show('Error getting current tab');
+ }
+ }
+
+ onClickRemoveHost(host: string) {
+ this.confirm.show(`Remove ${host} from whitelist?`, async () => {
+ await this.storage.getSignerMetaHandler().removeWhitelistedHost(host);
+ this.toast.show(`Removed ${host} from whitelist`);
+ });
+ }
+
+ onClickBack() {
+ this.#router.navigateByUrl('/home/identities');
+ }
+}
diff --git a/projects/chrome/src/background-common.ts b/projects/chrome/src/background-common.ts
index 6eeccc3..ffa6cbe 100644
--- a/projects/chrome/src/background-common.ts
+++ b/projects/chrome/src/background-common.ts
@@ -48,6 +48,40 @@ export const getBrowserSessionData = async function (): Promise<
return browserSessionData as BrowserSessionData;
};
+export const getSignerMetaData = async function (): Promise {
+ const signerMetaHandler = new ChromeMetaHandler();
+ return (await signerMetaHandler.loadFullData()) as SignerMetaData;
+};
+
+/**
+ * Check if reckless mode should auto-approve the request.
+ * Returns true if should auto-approve, false if should use normal permission flow.
+ *
+ * Logic:
+ * - If reckless mode is OFF → return false (use normal flow)
+ * - If reckless mode is ON and whitelist is empty → return true (approve all)
+ * - If reckless mode is ON and whitelist has entries → return true only if host is in whitelist
+ */
+export const shouldRecklessModeApprove = async function (
+ host: string
+): Promise {
+ const signerMetaData = await getSignerMetaData();
+
+ if (!signerMetaData.recklessMode) {
+ return false;
+ }
+
+ const whitelistedHosts = signerMetaData.whitelistedHosts ?? [];
+
+ if (whitelistedHosts.length === 0) {
+ // Reckless mode ON, no whitelist → approve all
+ return true;
+ }
+
+ // Reckless mode ON, whitelist has entries → only approve if host is whitelisted
+ return whitelistedHosts.includes(host);
+};
+
export const getBrowserSyncData = async function (): Promise<
BrowserSyncData | undefined
> {
diff --git a/projects/chrome/src/background.ts b/projects/chrome/src/background.ts
index 01fa1cb..5577f1b 100644
--- a/projects/chrome/src/background.ts
+++ b/projects/chrome/src/background.ts
@@ -12,6 +12,7 @@ import {
nip44Encrypt,
PromptResponse,
PromptResponseMessage,
+ shouldRecklessModeApprove,
signEvent,
storePermission,
} from './background-common';
@@ -63,57 +64,65 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
}
const req = request as BackgroundRequestMessage;
- const permissionState = checkPermissions(
- browserSessionData,
- currentIdentity,
- req.host,
- req.method,
- req.params
- );
- if (permissionState === false) {
- throw new Error('Permission denied');
- }
+ // Check reckless mode first
+ const recklessApprove = await shouldRecklessModeApprove(req.host);
+ if (recklessApprove) {
+ debug('Request auto-approved via reckless mode.');
+ } else {
+ // Normal permission flow
+ const permissionState = checkPermissions(
+ browserSessionData,
+ currentIdentity,
+ req.host,
+ req.method,
+ req.params
+ );
- if (permissionState === undefined) {
- // Ask user for permission.
- const width = 375;
- const height = 600;
- const { top, left } = await getPosition(width, height);
-
- const base64Event = Buffer.from(
- JSON.stringify(req.params ?? {}, undefined, 2)
- ).toString('base64');
-
- const response = await new Promise((resolve, reject) => {
- const id = crypto.randomUUID();
- openPrompts.set(id, { resolve, reject });
- browser.windows.create({
- type: 'popup',
- url: `prompt.html?method=${req.method}&host=${req.host}&id=${id}&nick=${currentIdentity.nick}&event=${base64Event}`,
- height,
- width,
- top,
- left,
- });
- });
- debug(response);
- if (response === 'approve' || response === 'reject') {
- await storePermission(
- browserSessionData,
- currentIdentity,
- req.host,
- req.method,
- response === 'approve' ? 'allow' : 'deny',
- req.params?.kind
- );
- }
-
- if (['reject', 'reject-once'].includes(response)) {
+ if (permissionState === false) {
throw new Error('Permission denied');
}
- } else {
- debug('Request allowed (via saved permission).');
+
+ if (permissionState === undefined) {
+ // Ask user for permission.
+ const width = 375;
+ const height = 600;
+ const { top, left } = await getPosition(width, height);
+
+ const base64Event = Buffer.from(
+ JSON.stringify(req.params ?? {}, undefined, 2)
+ ).toString('base64');
+
+ const response = await new Promise((resolve, reject) => {
+ const id = crypto.randomUUID();
+ openPrompts.set(id, { resolve, reject });
+ browser.windows.create({
+ type: 'popup',
+ url: `prompt.html?method=${req.method}&host=${req.host}&id=${id}&nick=${currentIdentity.nick}&event=${base64Event}`,
+ height,
+ width,
+ top,
+ left,
+ });
+ });
+ debug(response);
+ if (response === 'approve' || response === 'reject') {
+ await storePermission(
+ browserSessionData,
+ currentIdentity,
+ req.host,
+ req.method,
+ response === 'approve' ? 'allow' : 'deny',
+ req.params?.kind
+ );
+ }
+
+ if (['reject', 'reject-once'].includes(response)) {
+ throw new Error('Permission denied');
+ }
+ } else {
+ debug('Request allowed (via saved permission).');
+ }
}
const relays: Relays = {};
diff --git a/projects/common/src/lib/services/storage/signer-meta-handler.ts b/projects/common/src/lib/services/storage/signer-meta-handler.ts
index f4b919c..bb8123e 100644
--- a/projects/common/src/lib/services/storage/signer-meta-handler.ts
+++ b/projects/common/src/lib/services/storage/signer-meta-handler.ts
@@ -8,7 +8,7 @@ export abstract class SignerMetaHandler {
#signerMetaData?: SignerMetaData;
- readonly metaProperties = ['syncFlow', 'vaultSnapshots'];
+ readonly metaProperties = ['syncFlow', 'vaultSnapshots', 'recklessMode', 'whitelistedHosts'];
/**
* Load the full data from the storage. If the storage is used for storing
* other data (e.g. browser sync data when the user decided to NOT sync),
@@ -40,4 +40,53 @@ export abstract class SignerMetaHandler {
}
abstract clearData(keep: string[]): Promise;
+
+ /**
+ * Sets the reckless mode and immediately saves it.
+ */
+ async setRecklessMode(enabled: boolean): Promise {
+ if (!this.#signerMetaData) {
+ this.#signerMetaData = {
+ recklessMode: enabled,
+ };
+ } else {
+ this.#signerMetaData.recklessMode = enabled;
+ }
+
+ await this.saveFullData(this.#signerMetaData);
+ }
+
+ /**
+ * Adds a host to the whitelist and immediately saves it.
+ */
+ async addWhitelistedHost(host: string): Promise {
+ if (!this.#signerMetaData) {
+ this.#signerMetaData = {
+ whitelistedHosts: [host],
+ };
+ } else {
+ const hosts = this.#signerMetaData.whitelistedHosts ?? [];
+ if (!hosts.includes(host)) {
+ hosts.push(host);
+ this.#signerMetaData.whitelistedHosts = hosts;
+ }
+ }
+
+ await this.saveFullData(this.#signerMetaData);
+ }
+
+ /**
+ * Removes a host from the whitelist and immediately saves it.
+ */
+ async removeWhitelistedHost(host: string): Promise {
+ if (!this.#signerMetaData?.whitelistedHosts) {
+ return;
+ }
+
+ this.#signerMetaData.whitelistedHosts = this.#signerMetaData.whitelistedHosts.filter(
+ (h) => h !== host
+ );
+
+ await this.saveFullData(this.#signerMetaData);
+ }
}
diff --git a/projects/common/src/lib/services/storage/types.ts b/projects/common/src/lib/services/storage/types.ts
index 4be0de2..971395c 100644
--- a/projects/common/src/lib/services/storage/types.ts
+++ b/projects/common/src/lib/services/storage/types.ts
@@ -92,6 +92,12 @@ export interface SignerMetaData {
syncFlow?: number; // 0 = no sync, 1 = browser sync, (future: 2 = Signer sync, 3 = Custom sync (bring your own sync))
vaultSnapshots?: SignerMetaData_VaultSnapshot[];
+
+ // Reckless mode: auto-approve all actions without prompting
+ recklessMode?: boolean;
+
+ // Whitelisted hosts: auto-approve all actions from these hosts
+ whitelistedHosts?: string[];
}
/**
diff --git a/projects/firefox/public/manifest.json b/projects/firefox/public/manifest.json
index 7463aa2..d630a14 100644
--- a/projects/firefox/public/manifest.json
+++ b/projects/firefox/public/manifest.json
@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Plebeian Signer",
"description": "Nostr Identity Manager & Signer",
- "version": "v0.0.8",
+ "version": "0.0.9",
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
"options_page": "options.html",
"permissions": [
diff --git a/projects/firefox/public/person-fill.svg b/projects/firefox/public/person-fill.svg
index 660a118..443f3c7 100644
--- a/projects/firefox/public/person-fill.svg
+++ b/projects/firefox/public/person-fill.svg
@@ -1,3 +1,3 @@
-
\ No newline at end of file
+
diff --git a/projects/firefox/src/app/app.routes.ts b/projects/firefox/src/app/app.routes.ts
index fd7cd58..c3f5777 100644
--- a/projects/firefox/src/app/app.routes.ts
+++ b/projects/firefox/src/app/app.routes.ts
@@ -16,6 +16,7 @@ import { WelcomeComponent } from './components/welcome/welcome.component';
import { VaultLoginComponent } from './components/vault-login/vault-login.component';
import { VaultCreateComponent } from './components/vault-create/vault-create.component';
import { VaultImportComponent } from './components/vault-import/vault-import.component';
+import { WhitelistedAppsComponent } from './components/whitelisted-apps/whitelisted-apps.component';
export const routes: Routes = [
{
@@ -70,6 +71,10 @@ export const routes: Routes = [
path: 'new-identity',
component: NewIdentityComponent,
},
+ {
+ path: 'whitelisted-apps',
+ component: WhitelistedAppsComponent,
+ },
{
path: 'edit-identity/:id',
component: EditIdentityComponent,
diff --git a/projects/firefox/src/app/components/home/identities/identities.component.html b/projects/firefox/src/app/components/home/identities/identities.component.html
index aae8b09..af3c483 100644
--- a/projects/firefox/src/app/components/home/identities/identities.component.html
+++ b/projects/firefox/src/app/components/home/identities/identities.component.html
@@ -11,6 +11,30 @@
+
+
+
+
+
@let sessionData = storage.getBrowserSessionHandler().browserSessionData;
@let identities = sessionData?.identities ?? [];
@@ -34,7 +58,7 @@
class="avatar"
[src]="getAvatarUrl(identity)"
alt=""
- (error)="$any($event.target).src = 'assets/person-fill.svg'"
+ (error)="$any($event.target).src = 'person-fill.svg'"
/>
{{ getDisplayName(identity) }}
profile for quick lookup
#profileCache = new Map();
+ get isRecklessMode(): boolean {
+ return this.storage.getSignerMetaHandler().signerMetaData?.recklessMode ?? false;
+ }
+
async ngOnInit() {
await this.#profileMetadata.initialize();
this.#loadProfiles();
@@ -40,7 +44,7 @@ export class IdentitiesComponent implements OnInit {
getAvatarUrl(identity: Identity_DECRYPTED): string {
const profile = this.#profileCache.get(identity.id);
- return profile?.picture || 'assets/person-fill.svg';
+ return profile?.picture || 'person-fill.svg';
}
getDisplayName(identity: Identity_DECRYPTED): string {
@@ -60,4 +64,13 @@ export class IdentitiesComponent implements OnInit {
async onClickSelectIdentity(identityId: string) {
await this.storage.switchIdentity(identityId);
}
+
+ async onToggleRecklessMode() {
+ const newValue = !this.isRecklessMode;
+ await this.storage.getSignerMetaHandler().setRecklessMode(newValue);
+ }
+
+ onClickWhitelistedApps() {
+ this.#router.navigateByUrl('/whitelisted-apps');
+ }
}
diff --git a/projects/firefox/src/app/components/whitelisted-apps/whitelisted-apps.component.html b/projects/firefox/src/app/components/whitelisted-apps/whitelisted-apps.component.html
new file mode 100644
index 0000000..1063baa
--- /dev/null
+++ b/projects/firefox/src/app/components/whitelisted-apps/whitelisted-apps.component.html
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+ @if (whitelistedHosts.length === 0) {
+
+ No whitelisted apps yet
+
+ @if (isRecklessMode) {
+
+ ⚠ All sites will be auto-approved without prompting
+
+ }
+ }
+
+ @for (host of whitelistedHosts; track host) {
+
+ {{ host }}
+
+
+ }
+
+
+
+
+
diff --git a/projects/firefox/src/app/components/whitelisted-apps/whitelisted-apps.component.scss b/projects/firefox/src/app/components/whitelisted-apps/whitelisted-apps.component.scss
new file mode 100644
index 0000000..73142fc
--- /dev/null
+++ b/projects/firefox/src/app/components/whitelisted-apps/whitelisted-apps.component.scss
@@ -0,0 +1,134 @@
+:host {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+
+ .custom-header {
+ padding: var(--size);
+ display: grid;
+ grid-template-columns: 1fr;
+ grid-template-rows: auto;
+ align-items: center;
+ background: var(--background);
+ position: sticky;
+ top: 0;
+ z-index: 10;
+
+ .back-btn {
+ grid-column-start: 1;
+ grid-column-end: 2;
+ grid-row-start: 1;
+ grid-row-end: 2;
+ justify-self: start;
+ background: transparent;
+ border: none;
+ color: var(--foreground);
+ padding: 4px 8px;
+ cursor: pointer;
+ border-radius: 4px;
+ z-index: 1;
+
+ &:hover {
+ background: var(--background-light);
+ }
+
+ i {
+ font-size: 20px;
+ }
+ }
+
+ .text {
+ grid-column-start: 1;
+ grid-column-end: 2;
+ grid-row-start: 1;
+ grid-row-end: 2;
+ font-family: var(--font-heading);
+ font-size: 20px;
+ font-weight: 700;
+ letter-spacing: 0.05rem;
+ justify-self: center;
+ }
+ }
+
+ .content {
+ padding: 0 var(--size) var(--size) var(--size);
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+
+ .whitelist-btn {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ margin-bottom: var(--size);
+ }
+
+ .hosts-list {
+ flex-grow: 1;
+
+ .empty-state {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 32px;
+ text-align: center;
+ }
+
+ .warning-note {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 12px;
+ background: rgba(255, 193, 7, 0.15);
+ border: 1px solid rgba(255, 193, 7, 0.4);
+ border-radius: 8px;
+ text-align: center;
+
+ span {
+ font-size: 13px;
+ color: #ffc107;
+ }
+ }
+
+ .host-item {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px;
+ background: var(--background-light);
+ border-radius: 8px;
+ margin-bottom: 8px;
+
+ .host-name {
+ font-size: 14px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .remove-btn {
+ background: transparent;
+ border: none;
+ color: var(--muted-foreground);
+ padding: 4px 8px;
+ cursor: pointer;
+ border-radius: 4px;
+ transition: color 0.15s ease, background-color 0.15s ease;
+
+ &:hover {
+ color: var(--destructive);
+ background: var(--background-light-hover);
+ }
+
+ i {
+ font-size: 14px;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/projects/firefox/src/app/components/whitelisted-apps/whitelisted-apps.component.ts b/projects/firefox/src/app/components/whitelisted-apps/whitelisted-apps.component.ts
new file mode 100644
index 0000000..dcc178d
--- /dev/null
+++ b/projects/firefox/src/app/components/whitelisted-apps/whitelisted-apps.component.ts
@@ -0,0 +1,73 @@
+import { Component, inject, ViewChild } from '@angular/core';
+import { Router } from '@angular/router';
+import {
+ ConfirmComponent,
+ NavComponent,
+ StorageService,
+ ToastComponent,
+} from '@common';
+import browser from 'webextension-polyfill';
+
+@Component({
+ selector: 'app-whitelisted-apps',
+ templateUrl: './whitelisted-apps.component.html',
+ styleUrl: './whitelisted-apps.component.scss',
+ imports: [ToastComponent, ConfirmComponent],
+})
+export class WhitelistedAppsComponent extends NavComponent {
+ @ViewChild('toast') toast!: ToastComponent;
+ @ViewChild('confirm') confirm!: ConfirmComponent;
+
+ readonly storage = inject(StorageService);
+ readonly #router = inject(Router);
+
+ get whitelistedHosts(): string[] {
+ return this.storage.getSignerMetaHandler().signerMetaData?.whitelistedHosts ?? [];
+ }
+
+ get isRecklessMode(): boolean {
+ return this.storage.getSignerMetaHandler().signerMetaData?.recklessMode ?? false;
+ }
+
+ async onClickWhitelistCurrentTab() {
+ try {
+ // Get current active tab
+ const tabs = await browser.tabs.query({ active: true, currentWindow: true });
+ if (tabs.length === 0 || !tabs[0].url) {
+ this.toast.show('No active tab found');
+ return;
+ }
+
+ const url = new URL(tabs[0].url);
+ const host = url.host;
+
+ if (!host) {
+ this.toast.show('Cannot get host from current tab');
+ return;
+ }
+
+ // Check if already whitelisted
+ if (this.whitelistedHosts.includes(host)) {
+ this.toast.show(`${host} is already whitelisted`);
+ return;
+ }
+
+ await this.storage.getSignerMetaHandler().addWhitelistedHost(host);
+ this.toast.show(`Added ${host} to whitelist`);
+ } catch (error) {
+ console.error('Error getting current tab:', error);
+ this.toast.show('Error getting current tab');
+ }
+ }
+
+ onClickRemoveHost(host: string) {
+ this.confirm.show(`Remove ${host} from whitelist?`, async () => {
+ await this.storage.getSignerMetaHandler().removeWhitelistedHost(host);
+ this.toast.show(`Removed ${host} from whitelist`);
+ });
+ }
+
+ onClickBack() {
+ this.#router.navigateByUrl('/home/identities');
+ }
+}
diff --git a/projects/firefox/src/background-common.ts b/projects/firefox/src/background-common.ts
index 14c9762..213c801 100644
--- a/projects/firefox/src/background-common.ts
+++ b/projects/firefox/src/background-common.ts
@@ -49,6 +49,40 @@ export const getBrowserSessionData = async function (): Promise<
return browserSessionData as unknown as BrowserSessionData;
};
+export const getSignerMetaData = async function (): Promise {
+ const signerMetaHandler = new FirefoxMetaHandler();
+ return (await signerMetaHandler.loadFullData()) as SignerMetaData;
+};
+
+/**
+ * Check if reckless mode should auto-approve the request.
+ * Returns true if should auto-approve, false if should use normal permission flow.
+ *
+ * Logic:
+ * - If reckless mode is OFF → return false (use normal flow)
+ * - If reckless mode is ON and whitelist is empty → return true (approve all)
+ * - If reckless mode is ON and whitelist has entries → return true only if host is in whitelist
+ */
+export const shouldRecklessModeApprove = async function (
+ host: string
+): Promise {
+ const signerMetaData = await getSignerMetaData();
+
+ if (!signerMetaData.recklessMode) {
+ return false;
+ }
+
+ const whitelistedHosts = signerMetaData.whitelistedHosts ?? [];
+
+ if (whitelistedHosts.length === 0) {
+ // Reckless mode ON, no whitelist → approve all
+ return true;
+ }
+
+ // Reckless mode ON, whitelist has entries → only approve if host is whitelisted
+ return whitelistedHosts.includes(host);
+};
+
export const getBrowserSyncData = async function (): Promise<
BrowserSyncData | undefined
> {
diff --git a/projects/firefox/src/background.ts b/projects/firefox/src/background.ts
index 01fa1cb..5577f1b 100644
--- a/projects/firefox/src/background.ts
+++ b/projects/firefox/src/background.ts
@@ -12,6 +12,7 @@ import {
nip44Encrypt,
PromptResponse,
PromptResponseMessage,
+ shouldRecklessModeApprove,
signEvent,
storePermission,
} from './background-common';
@@ -63,57 +64,65 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
}
const req = request as BackgroundRequestMessage;
- const permissionState = checkPermissions(
- browserSessionData,
- currentIdentity,
- req.host,
- req.method,
- req.params
- );
- if (permissionState === false) {
- throw new Error('Permission denied');
- }
+ // Check reckless mode first
+ const recklessApprove = await shouldRecklessModeApprove(req.host);
+ if (recklessApprove) {
+ debug('Request auto-approved via reckless mode.');
+ } else {
+ // Normal permission flow
+ const permissionState = checkPermissions(
+ browserSessionData,
+ currentIdentity,
+ req.host,
+ req.method,
+ req.params
+ );
- if (permissionState === undefined) {
- // Ask user for permission.
- const width = 375;
- const height = 600;
- const { top, left } = await getPosition(width, height);
-
- const base64Event = Buffer.from(
- JSON.stringify(req.params ?? {}, undefined, 2)
- ).toString('base64');
-
- const response = await new Promise((resolve, reject) => {
- const id = crypto.randomUUID();
- openPrompts.set(id, { resolve, reject });
- browser.windows.create({
- type: 'popup',
- url: `prompt.html?method=${req.method}&host=${req.host}&id=${id}&nick=${currentIdentity.nick}&event=${base64Event}`,
- height,
- width,
- top,
- left,
- });
- });
- debug(response);
- if (response === 'approve' || response === 'reject') {
- await storePermission(
- browserSessionData,
- currentIdentity,
- req.host,
- req.method,
- response === 'approve' ? 'allow' : 'deny',
- req.params?.kind
- );
- }
-
- if (['reject', 'reject-once'].includes(response)) {
+ if (permissionState === false) {
throw new Error('Permission denied');
}
- } else {
- debug('Request allowed (via saved permission).');
+
+ if (permissionState === undefined) {
+ // Ask user for permission.
+ const width = 375;
+ const height = 600;
+ const { top, left } = await getPosition(width, height);
+
+ const base64Event = Buffer.from(
+ JSON.stringify(req.params ?? {}, undefined, 2)
+ ).toString('base64');
+
+ const response = await new Promise((resolve, reject) => {
+ const id = crypto.randomUUID();
+ openPrompts.set(id, { resolve, reject });
+ browser.windows.create({
+ type: 'popup',
+ url: `prompt.html?method=${req.method}&host=${req.host}&id=${id}&nick=${currentIdentity.nick}&event=${base64Event}`,
+ height,
+ width,
+ top,
+ left,
+ });
+ });
+ debug(response);
+ if (response === 'approve' || response === 'reject') {
+ await storePermission(
+ browserSessionData,
+ currentIdentity,
+ req.host,
+ req.method,
+ response === 'approve' ? 'allow' : 'deny',
+ req.params?.kind
+ );
+ }
+
+ if (['reject', 'reject-once'].includes(response)) {
+ throw new Error('Permission denied');
+ }
+ } else {
+ debug('Request allowed (via saved permission).');
+ }
}
const relays: Relays = {};