1 Commits

Author SHA1 Message Date
ddb74c61b2 Release v0.0.9 - Add reckless mode with whitelisted apps
- Add reckless mode checkbox to auto-approve signing requests
- Implement whitelisted apps management page
- Reckless mode logic: allow all if whitelist empty, otherwise only whitelisted hosts
- Add shouldRecklessModeApprove() in background service worker
- Update default avatar to Plebeian Market Account icon
- Fix manifest version scripts to strip v prefix for browsers

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 10:56:45 +01:00
27 changed files with 952 additions and 116 deletions

View File

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

View File

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

View File

@@ -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": {

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": "v0.0.8",
"version": "0.0.9",
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
"options_page": "options.html",
"permissions": [

View File

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" fill="#ffffff" class="bi bi-person-fill" viewBox="0 0 16 16">
<path d="M3 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6"/>
</svg>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 4C11.606 4 11.2159 4.0776 10.8519 4.22836C10.488 4.37913 10.1573 4.6001 9.87868 4.87868C9.6001 5.15726 9.37913 5.48797 9.22836 5.85195C9.0776 6.21593 9 6.60603 9 7C9 7.39397 9.0776 7.78407 9.22836 8.14805C9.37913 8.51203 9.6001 8.84274 9.87868 9.12132C10.1573 9.3999 10.488 9.62087 10.8519 9.77164C11.2159 9.9224 11.606 10 12 10C12.7956 10 13.5587 9.68393 14.1213 9.12132C14.6839 8.55871 15 7.79565 15 7C15 6.20435 14.6839 5.44129 14.1213 4.87868C13.5587 4.31607 12.7956 4 12 4ZM7 7C7 5.67392 7.52678 4.40215 8.46447 3.46447C9.40215 2.52678 10.6739 2 12 2C13.3261 2 14.5979 2.52678 15.5355 3.46447C16.4732 4.40215 17 5.67392 17 7C17 8.32608 16.4732 9.59785 15.5355 10.5355C14.5979 11.4732 13.3261 12 12 12C10.6739 12 9.40215 11.4732 8.46447 10.5355C7.52678 9.59785 7 8.32608 7 7ZM3.5 19C3.5 17.6739 4.02678 16.4021 4.96447 15.4645C5.90215 14.5268 7.17392 14 8.5 14H15.5C16.1566 14 16.8068 14.1293 17.4134 14.3806C18.02 14.6319 18.5712 15.0002 19.0355 15.4645C19.4998 15.9288 19.8681 16.48 20.1194 17.0866C20.3707 17.6932 20.5 18.3434 20.5 19V21H18.5V19C18.5 18.2044 18.1839 17.4413 17.6213 16.8787C17.0587 16.3161 16.2956 16 15.5 16H8.5C7.70435 16 6.94129 16.3161 6.37868 16.8787C5.81607 17.4413 5.5 18.2044 5.5 19V21H3.5V19Z" fill="#FAFAFA"/>
</svg>

Before

Width:  |  Height:  |  Size: 219 B

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

View File

@@ -11,6 +11,30 @@
</button>
</div>
<div class="reckless-mode-row">
<label class="reckless-label" (click)="onToggleRecklessMode()">
<input
type="checkbox"
[checked]="isRecklessMode"
(click)="$event.stopPropagation()"
(change)="onToggleRecklessMode()"
/>
<span
class="reckless-text"
data-bs-toggle="tooltip"
data-bs-placement="bottom"
title="Auto-approve all actions. If whitelist has entries, only those apps are auto-approved."
>Reckless mode</span>
</label>
<button
class="gear-btn"
title="Manage whitelisted apps"
(click)="onClickWhitelistedApps()"
>
<i class="bi bi-gear"></i>
</button>
</div>
@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'"
/>
<span class="name">{{ getDisplayName(identity) }}</span>
<lib-icon-button

View File

@@ -28,13 +28,66 @@
grid-column-end: 2;
grid-row-start: 1;
grid-row-end: 2;
font-size: 20px;
font-weight: 500;
font-family: var(--font-heading);
font-size: 24px;
font-weight: 700;
letter-spacing: 0.1rem;
justify-self: center;
height: 32px;
}
}
.reckless-mode-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
margin-bottom: 12px;
background: var(--background-light);
border-radius: 8px;
.reckless-label {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--primary);
cursor: pointer;
}
.reckless-text {
font-size: 14px;
color: var(--foreground);
}
}
.gear-btn {
background: transparent;
border: none;
color: var(--muted-foreground);
padding: 4px 8px;
cursor: pointer;
border-radius: 4px;
transition: color 0.15s ease, background-color 0.15s ease;
&:hover {
color: var(--foreground);
background: var(--background-light-hover);
}
i {
font-size: 16px;
}
}
}
.empty-state {
height: 100%;
display: flex;

View File

@@ -24,6 +24,10 @@ export class IdentitiesComponent implements OnInit {
// Cache of pubkey -> profile for quick lookup
#profileCache = new Map<string, ProfileMetadata | null>();
get isRecklessMode(): boolean {
return this.storage.getSignerMetaHandler().signerMetaData?.recklessMode ?? false;
}
async ngOnInit() {
await this.#profileMetadata.initialize();
this.#loadProfiles();
@@ -40,7 +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');
}
}

View File

@@ -0,0 +1,45 @@
<div class="custom-header">
<button class="back-btn" (click)="onClickBack()">
<i class="bi bi-chevron-left"></i>
</button>
<span class="text">Whitelisted Apps</span>
</div>
<div class="content">
<button
class="btn btn-primary whitelist-btn"
(click)="onClickWhitelistCurrentTab()"
>
<i class="bi bi-plus-lg"></i>
<span>Whitelist current tab</span>
</button>
<div class="hosts-list">
@if (whitelistedHosts.length === 0) {
<div class="empty-state">
<span class="sam-text-muted">No whitelisted apps yet</span>
</div>
@if (isRecklessMode) {
<div class="warning-note">
<span>&#9888; All sites will be auto-approved without prompting</span>
</div>
}
}
@for (host of whitelistedHosts; track host) {
<div class="host-item">
<span class="host-name">{{ host }}</span>
<button
class="remove-btn"
title="Remove from whitelist"
(click)="onClickRemoveHost(host)"
>
<i class="bi bi-trash"></i>
</button>
</div>
}
</div>
</div>
<lib-toast #toast></lib-toast>
<lib-confirm #confirm></lib-confirm>

View File

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

View File

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

View File

@@ -48,6 +48,40 @@ export const getBrowserSessionData = async function (): Promise<
return browserSessionData as BrowserSessionData;
};
export const getSignerMetaData = async function (): Promise<SignerMetaData> {
const signerMetaHandler = new ChromeMetaHandler();
return (await signerMetaHandler.loadFullData()) as SignerMetaData;
};
/**
* Check if reckless mode should auto-approve the request.
* Returns true if should auto-approve, false if should use normal permission flow.
*
* Logic:
* - If reckless mode is OFF → return false (use normal flow)
* - If reckless mode is ON and whitelist is empty → return true (approve all)
* - If reckless mode is ON and whitelist has entries → return true only if host is in whitelist
*/
export const shouldRecklessModeApprove = async function (
host: string
): Promise<boolean> {
const signerMetaData = await getSignerMetaData();
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
> {

View File

@@ -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<PromptResponse>((resolve, reject) => {
const id = crypto.randomUUID();
openPrompts.set(id, { resolve, reject });
browser.windows.create({
type: 'popup',
url: `prompt.html?method=${req.method}&host=${req.host}&id=${id}&nick=${currentIdentity.nick}&event=${base64Event}`,
height,
width,
top,
left,
});
});
debug(response);
if (response === 'approve' || response === 'reject') {
await storePermission(
browserSessionData,
currentIdentity,
req.host,
req.method,
response === 'approve' ? 'allow' : 'deny',
req.params?.kind
);
}
if (['reject', 'reject-once'].includes(response)) {
if (permissionState === false) {
throw new Error('Permission denied');
}
} else {
debug('Request allowed (via saved permission).');
if (permissionState === undefined) {
// Ask user for permission.
const width = 375;
const height = 600;
const { top, left } = await getPosition(width, height);
const base64Event = Buffer.from(
JSON.stringify(req.params ?? {}, undefined, 2)
).toString('base64');
const response = await new Promise<PromptResponse>((resolve, reject) => {
const id = crypto.randomUUID();
openPrompts.set(id, { resolve, reject });
browser.windows.create({
type: 'popup',
url: `prompt.html?method=${req.method}&host=${req.host}&id=${id}&nick=${currentIdentity.nick}&event=${base64Event}`,
height,
width,
top,
left,
});
});
debug(response);
if (response === 'approve' || response === 'reject') {
await storePermission(
browserSessionData,
currentIdentity,
req.host,
req.method,
response === 'approve' ? 'allow' : 'deny',
req.params?.kind
);
}
if (['reject', 'reject-once'].includes(response)) {
throw new Error('Permission denied');
}
} else {
debug('Request allowed (via saved permission).');
}
}
const relays: Relays = {};

View File

@@ -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<void>;
/**
* Sets the reckless mode and immediately saves it.
*/
async setRecklessMode(enabled: boolean): Promise<void> {
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<void> {
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<void> {
if (!this.#signerMetaData?.whitelistedHosts) {
return;
}
this.#signerMetaData.whitelistedHosts = this.#signerMetaData.whitelistedHosts.filter(
(h) => h !== host
);
await this.saveFullData(this.#signerMetaData);
}
}

View File

@@ -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[];
}
/**

View File

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

View File

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" fill="#ffffff" class="bi bi-person-fill" viewBox="0 0 16 16">
<path d="M3 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6"/>
</svg>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 4C11.606 4 11.2159 4.0776 10.8519 4.22836C10.488 4.37913 10.1573 4.6001 9.87868 4.87868C9.6001 5.15726 9.37913 5.48797 9.22836 5.85195C9.0776 6.21593 9 6.60603 9 7C9 7.39397 9.0776 7.78407 9.22836 8.14805C9.37913 8.51203 9.6001 8.84274 9.87868 9.12132C10.1573 9.3999 10.488 9.62087 10.8519 9.77164C11.2159 9.9224 11.606 10 12 10C12.7956 10 13.5587 9.68393 14.1213 9.12132C14.6839 8.55871 15 7.79565 15 7C15 6.20435 14.6839 5.44129 14.1213 4.87868C13.5587 4.31607 12.7956 4 12 4ZM7 7C7 5.67392 7.52678 4.40215 8.46447 3.46447C9.40215 2.52678 10.6739 2 12 2C13.3261 2 14.5979 2.52678 15.5355 3.46447C16.4732 4.40215 17 5.67392 17 7C17 8.32608 16.4732 9.59785 15.5355 10.5355C14.5979 11.4732 13.3261 12 12 12C10.6739 12 9.40215 11.4732 8.46447 10.5355C7.52678 9.59785 7 8.32608 7 7ZM3.5 19C3.5 17.6739 4.02678 16.4021 4.96447 15.4645C5.90215 14.5268 7.17392 14 8.5 14H15.5C16.1566 14 16.8068 14.1293 17.4134 14.3806C18.02 14.6319 18.5712 15.0002 19.0355 15.4645C19.4998 15.9288 19.8681 16.48 20.1194 17.0866C20.3707 17.6932 20.5 18.3434 20.5 19V21H18.5V19C18.5 18.2044 18.1839 17.4413 17.6213 16.8787C17.0587 16.3161 16.2956 16 15.5 16H8.5C7.70435 16 6.94129 16.3161 6.37868 16.8787C5.81607 17.4413 5.5 18.2044 5.5 19V21H3.5V19Z" fill="#FAFAFA"/>
</svg>

Before

Width:  |  Height:  |  Size: 219 B

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

View File

@@ -11,6 +11,30 @@
</button>
</div>
<div class="reckless-mode-row">
<label class="reckless-label" (click)="onToggleRecklessMode()">
<input
type="checkbox"
[checked]="isRecklessMode"
(click)="$event.stopPropagation()"
(change)="onToggleRecklessMode()"
/>
<span
class="reckless-text"
data-bs-toggle="tooltip"
data-bs-placement="bottom"
title="Auto-approve all actions. If whitelist has entries, only those apps are auto-approved."
>Reckless mode</span>
</label>
<button
class="gear-btn"
title="Manage whitelisted apps"
(click)="onClickWhitelistedApps()"
>
<i class="bi bi-gear"></i>
</button>
</div>
@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'"
/>
<span class="name">{{ getDisplayName(identity) }}</span>
<lib-icon-button

View File

@@ -28,13 +28,66 @@
grid-column-end: 2;
grid-row-start: 1;
grid-row-end: 2;
font-size: 20px;
font-weight: 500;
font-family: var(--font-heading);
font-size: 24px;
font-weight: 700;
letter-spacing: 0.1rem;
justify-self: center;
height: 32px;
}
}
.reckless-mode-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
margin-bottom: 12px;
background: var(--background-light);
border-radius: 8px;
.reckless-label {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--primary);
cursor: pointer;
}
.reckless-text {
font-size: 14px;
color: var(--foreground);
}
}
.gear-btn {
background: transparent;
border: none;
color: var(--muted-foreground);
padding: 4px 8px;
cursor: pointer;
border-radius: 4px;
transition: color 0.15s ease, background-color 0.15s ease;
&:hover {
color: var(--foreground);
background: var(--background-light-hover);
}
i {
font-size: 16px;
}
}
}
.empty-state {
height: 100%;
display: flex;

View File

@@ -24,6 +24,10 @@ export class IdentitiesComponent implements OnInit {
// Cache of pubkey -> profile for quick lookup
#profileCache = new Map<string, ProfileMetadata | null>();
get isRecklessMode(): boolean {
return this.storage.getSignerMetaHandler().signerMetaData?.recklessMode ?? false;
}
async ngOnInit() {
await this.#profileMetadata.initialize();
this.#loadProfiles();
@@ -40,7 +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');
}
}

View File

@@ -0,0 +1,45 @@
<div class="custom-header">
<button class="back-btn" (click)="onClickBack()">
<i class="bi bi-chevron-left"></i>
</button>
<span class="text">Whitelisted Apps</span>
</div>
<div class="content">
<button
class="btn btn-primary whitelist-btn"
(click)="onClickWhitelistCurrentTab()"
>
<i class="bi bi-plus-lg"></i>
<span>Whitelist current tab</span>
</button>
<div class="hosts-list">
@if (whitelistedHosts.length === 0) {
<div class="empty-state">
<span class="sam-text-muted">No whitelisted apps yet</span>
</div>
@if (isRecklessMode) {
<div class="warning-note">
<span>&#9888; All sites will be auto-approved without prompting</span>
</div>
}
}
@for (host of whitelistedHosts; track host) {
<div class="host-item">
<span class="host-name">{{ host }}</span>
<button
class="remove-btn"
title="Remove from whitelist"
(click)="onClickRemoveHost(host)"
>
<i class="bi bi-trash"></i>
</button>
</div>
}
</div>
</div>
<lib-toast #toast></lib-toast>
<lib-confirm #confirm></lib-confirm>

View File

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

View File

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

View File

@@ -49,6 +49,40 @@ export const getBrowserSessionData = async function (): Promise<
return browserSessionData as unknown as BrowserSessionData;
};
export const getSignerMetaData = async function (): Promise<SignerMetaData> {
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<boolean> {
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
> {

View File

@@ -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<PromptResponse>((resolve, reject) => {
const id = crypto.randomUUID();
openPrompts.set(id, { resolve, reject });
browser.windows.create({
type: 'popup',
url: `prompt.html?method=${req.method}&host=${req.host}&id=${id}&nick=${currentIdentity.nick}&event=${base64Event}`,
height,
width,
top,
left,
});
});
debug(response);
if (response === 'approve' || response === 'reject') {
await storePermission(
browserSessionData,
currentIdentity,
req.host,
req.method,
response === 'approve' ? 'allow' : 'deny',
req.params?.kind
);
}
if (['reject', 'reject-once'].includes(response)) {
if (permissionState === false) {
throw new Error('Permission denied');
}
} else {
debug('Request allowed (via saved permission).');
if (permissionState === undefined) {
// Ask user for permission.
const width = 375;
const height = 600;
const { top, left } = await getPosition(width, height);
const base64Event = Buffer.from(
JSON.stringify(req.params ?? {}, undefined, 2)
).toString('base64');
const response = await new Promise<PromptResponse>((resolve, reject) => {
const id = crypto.randomUUID();
openPrompts.set(id, { resolve, reject });
browser.windows.create({
type: 'popup',
url: `prompt.html?method=${req.method}&host=${req.host}&id=${id}&nick=${currentIdentity.nick}&event=${base64Event}`,
height,
width,
top,
left,
});
});
debug(response);
if (response === 'approve' || response === 'reject') {
await storePermission(
browserSessionData,
currentIdentity,
req.host,
req.method,
response === 'approve' ? 'allow' : 'deny',
req.params?.kind
);
}
if (['reject', 'reject-once'].includes(response)) {
throw new Error('Permission denied');
}
} else {
debug('Request allowed (via saved permission).');
}
}
const relays: Relays = {};