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>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>⚠ 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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
> {
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
Reference in New Issue
Block a user