first chrome implementation
This commit is contained in:
1
projects/chrome/src/app/app.component.html
Normal file
1
projects/chrome/src/app/app.component.html
Normal file
@@ -0,0 +1 @@
|
||||
<router-outlet></router-outlet>
|
||||
0
projects/chrome/src/app/app.component.scss
Normal file
0
projects/chrome/src/app/app.component.scss
Normal file
29
projects/chrome/src/app/app.component.spec.ts
Normal file
29
projects/chrome/src/app/app.component.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AppComponent],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it(`should have the 'chrome' title`, () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app.title).toEqual('chrome');
|
||||
});
|
||||
|
||||
it('should render title', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, chrome');
|
||||
});
|
||||
});
|
||||
20
projects/chrome/src/app/app.component.ts
Normal file
20
projects/chrome/src/app/app.component.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { LoggerService } from '@common';
|
||||
import { StartupService } from './services/startup/startup.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss',
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
readonly #startup = inject(StartupService);
|
||||
readonly #logger = inject(LoggerService);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.#logger.initialize('Gooti Chrome Extension');
|
||||
this.#startup.startOver();
|
||||
}
|
||||
}
|
||||
8
projects/chrome/src/app/app.config.ts
Normal file
8
projects/chrome/src/app/app.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes)]
|
||||
};
|
||||
90
projects/chrome/src/app/app.routes.ts
Normal file
90
projects/chrome/src/app/app.routes.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { HomeComponent } from './components/home/home.component';
|
||||
import { VaultLoginComponent } from './components/vault-login/vault-login.component';
|
||||
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';
|
||||
import { SettingsComponent } from './components/home/settings/settings.component';
|
||||
import { NewIdentityComponent } from './components/new-identity/new-identity.component';
|
||||
import { EditIdentityComponent } from './components/edit-identity/edit-identity.component';
|
||||
import { HomeComponent as EditIdentityHomeComponent } from './components/edit-identity/home/home.component';
|
||||
import { KeysComponent as EditIdentityKeysComponent } from './components/edit-identity/keys/keys.component';
|
||||
import { PermissionsComponent as EditIdentityPermissionsComponent } from './components/edit-identity/permissions/permissions.component';
|
||||
import { RelaysComponent as EditIdentityRelaysComponent } from './components/edit-identity/relays/relays.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: 'welcome',
|
||||
component: WelcomeComponent,
|
||||
},
|
||||
{
|
||||
path: 'vault-login',
|
||||
component: VaultLoginComponent,
|
||||
},
|
||||
{
|
||||
path: 'vault-create',
|
||||
component: VaultCreateComponent,
|
||||
children: [
|
||||
{
|
||||
path: 'home',
|
||||
component: VaultCreateHomeComponent,
|
||||
},
|
||||
{
|
||||
path: 'new',
|
||||
component: VaultCreateNewComponent,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'home',
|
||||
component: HomeComponent,
|
||||
children: [
|
||||
{
|
||||
path: 'identities',
|
||||
component: IdentitiesComponent,
|
||||
},
|
||||
{
|
||||
path: 'identity',
|
||||
component: IdentityComponent,
|
||||
},
|
||||
{
|
||||
path: 'info',
|
||||
component: InfoComponent,
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
component: SettingsComponent,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'new-identity',
|
||||
component: NewIdentityComponent,
|
||||
},
|
||||
{
|
||||
path: 'edit-identity/:id',
|
||||
component: EditIdentityComponent,
|
||||
children: [
|
||||
{
|
||||
path: 'home',
|
||||
component: EditIdentityHomeComponent,
|
||||
},
|
||||
{
|
||||
path: 'keys',
|
||||
component: EditIdentityKeysComponent,
|
||||
},
|
||||
{
|
||||
path: 'permissions',
|
||||
component: EditIdentityPermissionsComponent,
|
||||
},
|
||||
{
|
||||
path: 'relays',
|
||||
component: EditIdentityRelaysComponent,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
29
projects/chrome/src/app/common/data/chrome-meta-handler.ts
Normal file
29
projects/chrome/src/app/common/data/chrome-meta-handler.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { GootiMetaData, GootiMetaHandler } from '@common';
|
||||
|
||||
export class ChromeMetaHandler extends GootiMetaHandler {
|
||||
async loadFullData(): Promise<Partial<Record<string, any>>> {
|
||||
const dataWithPossibleAlienProperties = await chrome.storage.local.get(
|
||||
null
|
||||
);
|
||||
|
||||
if (Object.keys(dataWithPossibleAlienProperties).length === 0) {
|
||||
return dataWithPossibleAlienProperties;
|
||||
}
|
||||
|
||||
const data: Partial<Record<string, any>> = {};
|
||||
this.metaProperties.forEach((property) => {
|
||||
data[property] = dataWithPossibleAlienProperties[property];
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async saveFullData(data: GootiMetaData): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
}
|
||||
|
||||
async clearData(): Promise<void> {
|
||||
await chrome.storage.local.remove(this.metaProperties);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { BrowserSessionData, BrowserSessionHandler } from '@common';
|
||||
|
||||
export class ChromeSessionHandler extends BrowserSessionHandler {
|
||||
async loadFullData(): Promise<Partial<Record<string, any>>> {
|
||||
return chrome.storage.session.get(null);
|
||||
}
|
||||
|
||||
async saveFullData(data: BrowserSessionData): Promise<void> {
|
||||
await chrome.storage.session.set(data);
|
||||
}
|
||||
|
||||
async clearData(): Promise<void> {
|
||||
await chrome.storage.session.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
BrowserSyncData,
|
||||
BrowserSyncHandler,
|
||||
Identity_ENCRYPTED,
|
||||
Permission_ENCRYPTED,
|
||||
Relay_ENCRYPTED,
|
||||
} from '@common';
|
||||
|
||||
/**
|
||||
* Handles the browser "sync data" when the user does not want to sync anything.
|
||||
* It uses the chrome.storage.local API to store the data. Since we also use this API
|
||||
* to store local Gooti system data (like the user's decision to not sync), we
|
||||
* have to exclude these properties from the sync data.
|
||||
*/
|
||||
export class ChromeSyncNoHandler extends BrowserSyncHandler {
|
||||
async loadUnmigratedData(): Promise<Partial<Record<string, any>>> {
|
||||
const data = await chrome.storage.local.get(null);
|
||||
|
||||
// Remove any available "ignore properties".
|
||||
this.ignoreProperties.forEach((property) => {
|
||||
delete data[property];
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async saveAndSetFullData(data: BrowserSyncData): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setFullData(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Permissions(data: {
|
||||
permissions: Permission_ENCRYPTED[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setPartialData_Permissions(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Identities(data: {
|
||||
identities: Identity_ENCRYPTED[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setPartialData_Identities(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_SelectedIdentityId(data: {
|
||||
selectedIdentityId: string | null;
|
||||
}): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setPartialData_SelectedIdentityId(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Relays(data: {
|
||||
relays: Relay_ENCRYPTED[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.local.set(data);
|
||||
this.setPartialData_Relays(data);
|
||||
}
|
||||
|
||||
async clearData(): Promise<void> {
|
||||
const props = Object.keys(await this.loadUnmigratedData());
|
||||
await chrome.storage.local.remove(props);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
BrowserSyncData,
|
||||
Identity_ENCRYPTED,
|
||||
Permission_ENCRYPTED,
|
||||
BrowserSyncHandler,
|
||||
Relay_ENCRYPTED,
|
||||
} from '@common';
|
||||
|
||||
/**
|
||||
* Handles the browser sync operations when the browser sync is enabled.
|
||||
* If it's not enabled, it behaves like the local extension storage (which is fine).
|
||||
*/
|
||||
export class ChromeSyncYesHandler extends BrowserSyncHandler {
|
||||
async loadUnmigratedData(): Promise<Partial<Record<string, any>>> {
|
||||
return await chrome.storage.sync.get(null);
|
||||
}
|
||||
|
||||
async saveAndSetFullData(data: BrowserSyncData): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setFullData(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Permissions(data: {
|
||||
permissions: Permission_ENCRYPTED[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setPartialData_Permissions(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Identities(data: {
|
||||
identities: Identity_ENCRYPTED[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setPartialData_Identities(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_SelectedIdentityId(data: {
|
||||
selectedIdentityId: string | null;
|
||||
}): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setPartialData_SelectedIdentityId(data);
|
||||
}
|
||||
|
||||
async saveAndSetPartialData_Relays(data: {
|
||||
relays: Relay_ENCRYPTED[];
|
||||
}): Promise<void> {
|
||||
await chrome.storage.sync.set(data);
|
||||
this.setPartialData_Relays(data);
|
||||
}
|
||||
|
||||
async clearData(): Promise<void> {
|
||||
await chrome.storage.sync.clear();
|
||||
}
|
||||
}
|
||||
95
projects/chrome/src/app/common/extensions/array.ts
Normal file
95
projects/chrome/src/app/common/extensions/array.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
declare global {
|
||||
interface Array<T> {
|
||||
/**
|
||||
* Sorts the array by the provided property and returns a new sorted array.
|
||||
* Default sorting is ASC. You can apply DESC sorting by using the optional parameter "order = 'desc'"
|
||||
*/
|
||||
sortBy<K>(keyFunction: (t: T) => K, order?: 'asc' | 'desc'): T[];
|
||||
|
||||
/** Check if the array is empty. */
|
||||
empty(): boolean;
|
||||
|
||||
groupBy<K, R>(
|
||||
keyFunction: (t: T) => K,
|
||||
reduceFn: (items: T[]) => R
|
||||
): Map<K, R>;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.prototype.empty) {
|
||||
Array.prototype.empty = function (): boolean {
|
||||
return this.length === 0;
|
||||
};
|
||||
}
|
||||
|
||||
if (!Array.prototype.sortBy) {
|
||||
Array.prototype.sortBy = function <T, K>(
|
||||
keyFunction: (t: T) => K,
|
||||
order?: string
|
||||
): T[] {
|
||||
if (this.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// determine sort order (asc or desc / asc is default)
|
||||
let asc = true;
|
||||
if (order === 'desc') {
|
||||
asc = false;
|
||||
}
|
||||
|
||||
const arrayClone = Array.from(this) as any[];
|
||||
const firstSortProperty = keyFunction(arrayClone[0]);
|
||||
|
||||
if (typeof firstSortProperty === 'string') {
|
||||
// string in-place sort
|
||||
arrayClone.sort((a, b) => {
|
||||
if (asc) {
|
||||
return ('' + (keyFunction(a) as unknown as string)).localeCompare(
|
||||
keyFunction(b) as unknown as string
|
||||
);
|
||||
}
|
||||
|
||||
return ('' + (keyFunction(b) as unknown as string)).localeCompare(
|
||||
keyFunction(a) as unknown as string
|
||||
);
|
||||
});
|
||||
} else if (typeof firstSortProperty === 'number') {
|
||||
// number in-place sort
|
||||
if (asc) {
|
||||
arrayClone.sort(
|
||||
(a, b) => Number(keyFunction(a)) - Number(keyFunction(b))
|
||||
);
|
||||
} else {
|
||||
arrayClone.sort(
|
||||
(a, b) => Number(keyFunction(b)) - Number(keyFunction(a))
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new Error('sortBy is not implemented for that type!');
|
||||
}
|
||||
|
||||
return arrayClone;
|
||||
};
|
||||
}
|
||||
|
||||
if (!Array.prototype.groupBy) {
|
||||
Array.prototype.groupBy = function <T>(
|
||||
fn: (item: T) => any,
|
||||
reduceFn: (items: T[]) => any
|
||||
): Map<any, any> {
|
||||
const result = new Map<any, any>();
|
||||
|
||||
const distinctKeys = new Set<any>(this.map((x) => fn(x)));
|
||||
|
||||
for (const distinctKey of distinctKeys) {
|
||||
const distinctKeyItems = this.filter((x) => fn(x) === distinctKey);
|
||||
|
||||
result.set(distinctKey, reduceFn(distinctKeyItems));
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="custom-header">
|
||||
<lib-icon-button
|
||||
class="button"
|
||||
icon="chevron-left"
|
||||
(click)="onClickCancel()"
|
||||
></lib-icon-button>
|
||||
|
||||
<span class="text">{{ identity?.nick }} </span>
|
||||
</div>
|
||||
|
||||
<div class="edit-identity-outlet">
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
@@ -0,0 +1,47 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: hidden;
|
||||
overflow-x: hidden;
|
||||
|
||||
.custom-header {
|
||||
padding-top: var(--size);
|
||||
padding-bottom: var(--size);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto;
|
||||
align-items: center;
|
||||
background: var(--background);
|
||||
|
||||
.button {
|
||||
grid-column-start: 1;
|
||||
grid-column-end: 2;
|
||||
grid-row-start: 1;
|
||||
grid-row-end: 2;
|
||||
justify-self: start;
|
||||
margin-left: 16px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.text {
|
||||
grid-column-start: 1;
|
||||
grid-column-end: 2;
|
||||
grid-row-start: 1;
|
||||
grid-row-end: 2;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
justify-self: center;
|
||||
height: 32px;
|
||||
overflow-x: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 70%;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-identity-outlet {
|
||||
flex-grow: 1;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { EditIdentityComponent } from './edit-identity.component';
|
||||
|
||||
describe('EditIdentityComponent', () => {
|
||||
let component: EditIdentityComponent;
|
||||
let fixture: ComponentFixture<EditIdentityComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [EditIdentityComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(EditIdentityComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
|
||||
import { IconButtonComponent, Identity_DECRYPTED, StorageService } from '@common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-edit-identity',
|
||||
templateUrl: './edit-identity.component.html',
|
||||
styleUrl: './edit-identity.component.scss',
|
||||
imports: [RouterOutlet, IconButtonComponent],
|
||||
})
|
||||
export class EditIdentityComponent implements OnInit {
|
||||
identity?: Identity_DECRYPTED;
|
||||
previousRoute?: string;
|
||||
|
||||
readonly #activatedRoute = inject(ActivatedRoute);
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
|
||||
constructor() {
|
||||
// Must be called in the constructor and NOT in ngOnInit.
|
||||
this.previousRoute = this.#router
|
||||
.getCurrentNavigation()
|
||||
?.previousNavigation?.extractedUrl.toString();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
const selectedIdentityId = this.#activatedRoute.snapshot.params['id'];
|
||||
if (!selectedIdentityId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.identity = this.#storage
|
||||
.getBrowserSessionHandler()
|
||||
.browserSessionData?.identities.find((x) => x.id === selectedIdentityId);
|
||||
}
|
||||
|
||||
onClickCancel() {
|
||||
if (!this.previousRoute) {
|
||||
return;
|
||||
}
|
||||
this.#router.navigateByUrl(this.previousRoute);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<lib-nav-item text="Keys" (click)="onClickNavigateTo('keys')"></lib-nav-item>
|
||||
|
||||
<lib-nav-item
|
||||
text="Relays"
|
||||
(click)="onClickNavigateTo('relays')"
|
||||
></lib-nav-item>
|
||||
|
||||
<lib-nav-item
|
||||
text="Permissions"
|
||||
(click)="onClickNavigateTo('permissions')"
|
||||
></lib-nav-item>
|
||||
|
||||
<div class="sam-flex-grow"></div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
(click)="
|
||||
confirm.show(
|
||||
'Do you really want to delete this identity?',
|
||||
onConfirmDeletion.bind(this)
|
||||
)
|
||||
"
|
||||
>
|
||||
Delete Identity
|
||||
</button>
|
||||
|
||||
<lib-confirm #confirm> </lib-confirm>
|
||||
@@ -0,0 +1,8 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
padding-bottom: var(--size);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { HomeComponent } from './home.component';
|
||||
|
||||
describe('HomeComponent', () => {
|
||||
let component: HomeComponent;
|
||||
let fixture: ComponentFixture<HomeComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [HomeComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(HomeComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { NavItemComponent } from '../../../../../../common/src/lib/components/nav-item/nav-item.component';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { ConfirmComponent, Identity_DECRYPTED, StorageService } from '@common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
imports: [NavItemComponent, ConfirmComponent],
|
||||
templateUrl: './home.component.html',
|
||||
styleUrl: './home.component.scss',
|
||||
})
|
||||
export class HomeComponent implements OnInit {
|
||||
identity?: Identity_DECRYPTED;
|
||||
|
||||
readonly #activatedRoute = inject(ActivatedRoute);
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
|
||||
ngOnInit(): void {
|
||||
const identityId = this.#activatedRoute.parent?.snapshot.params['id'];
|
||||
if (!identityId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#initialize(identityId);
|
||||
}
|
||||
|
||||
onClickNavigateTo(destination: 'keys' | 'permissions' | 'relays') {
|
||||
this.#router.navigateByUrl(
|
||||
`/edit-identity/${this.identity?.id}/${destination}`
|
||||
);
|
||||
}
|
||||
|
||||
async onConfirmDeletion() {
|
||||
await this.#storage.deleteIdentity(this.identity?.id);
|
||||
await this.#router.navigateByUrl('/home/identities');
|
||||
}
|
||||
|
||||
#initialize(selectedIdentityId: string) {
|
||||
this.identity = this.#storage
|
||||
.getBrowserSessionHandler()
|
||||
.browserSessionData?.identities.find((x) => x.id === selectedIdentityId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<div class="header-pane">
|
||||
<lib-icon-button
|
||||
icon="chevron-left"
|
||||
(click)="navigateBack()"
|
||||
></lib-icon-button>
|
||||
<span>Keys</span>
|
||||
</div>
|
||||
|
||||
@if(identity) {
|
||||
<span>Public Key</span>
|
||||
|
||||
<!-- PUBKEY NPUB -->
|
||||
<div class="sam-mt-h sam-flex-row gap">
|
||||
<span class="text-muted" style="width: 48px">NPUB</span>
|
||||
<div class="input-group">
|
||||
<input
|
||||
id="pubkeyNpubInput"
|
||||
#pubkeyNpubInput
|
||||
type="text"
|
||||
class="form-control"
|
||||
[ngModel]="identity.pubkeyNpub"
|
||||
[readOnly]="true"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
type="button"
|
||||
(click)="
|
||||
copyToClipboard(identity.pubkeyNpub); toast.show('Copied to clipboard')
|
||||
"
|
||||
>
|
||||
<i
|
||||
class="bi bi-copy"
|
||||
[class.bi-eye]="pubkeyNpubInput.type === 'password'"
|
||||
[class.bi-eye-slash]="pubkeyNpubInput.type === 'text'"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PUBKEY HEX -->
|
||||
<div class="sam-mt-h sam-flex-row gap">
|
||||
<span class="text-muted" style="width: 48px">HEX</span>
|
||||
<div class="input-group">
|
||||
<input
|
||||
id="pubkeyHexInput"
|
||||
#pubkeyHexInput
|
||||
type="text"
|
||||
class="form-control"
|
||||
[ngModel]="identity.pubkeyHex"
|
||||
[readOnly]="true"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
type="button"
|
||||
(click)="
|
||||
copyToClipboard(identity.pubkeyHex); toast.show('Copied to clipboard')
|
||||
"
|
||||
>
|
||||
<i
|
||||
class="bi bi-copy"
|
||||
[class.bi-eye]="pubkeyHexInput.type === 'password'"
|
||||
[class.bi-eye-slash]="pubkeyHexInput.type === 'text'"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="sam-mt-2">Private Key</span>
|
||||
|
||||
<!-- PRIVATE NSEC -->
|
||||
<div class="sam-mt-h sam-flex-row gap">
|
||||
<span class="text-muted" style="width: 48px">NSEC</span>
|
||||
<div class="input-group">
|
||||
<input
|
||||
id="privkeyNsecInput"
|
||||
#privkeyNsecInput
|
||||
type="password"
|
||||
class="form-control"
|
||||
[ngModel]="identity.privkeyNsec"
|
||||
[readOnly]="true"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
type="button"
|
||||
(click)="
|
||||
copyToClipboard(identity.privkeyNsec); toast.show('Copied to clipboard')
|
||||
"
|
||||
>
|
||||
<i class="bi bi-copy"></i>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
type="button"
|
||||
(click)="toggleType(privkeyNsecInput)"
|
||||
>
|
||||
<i
|
||||
class="bi bi-eye"
|
||||
[class.bi-eye]="privkeyNsecInput.type === 'password'"
|
||||
[class.bi-eye-slash]="privkeyNsecInput.type === 'text'"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PRIVATE HEX -->
|
||||
<div class="sam-mt-h sam-flex-row gap">
|
||||
<span class="text-muted" style="width: 48px">HEX</span>
|
||||
<div class="input-group">
|
||||
<input
|
||||
id="privkeyHexInput"
|
||||
#privkeyHexInput
|
||||
type="password"
|
||||
class="form-control"
|
||||
[ngModel]="identity.privkeyHex"
|
||||
[readOnly]="true"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
type="button"
|
||||
(click)="
|
||||
copyToClipboard(identity.privkeyHex); toast.show('Copied to clipboard')
|
||||
"
|
||||
>
|
||||
<i class="bi bi-copy"></i>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
type="button"
|
||||
(click)="toggleType(privkeyHexInput)"
|
||||
>
|
||||
<i
|
||||
class="bi bi-eye"
|
||||
[class.bi-eye]="privkeyHexInput.type === 'password'"
|
||||
[class.bi-eye-slash]="privkeyHexInput.type === 'text'"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<lib-toast #toast [bottom]="16"></lib-toast>
|
||||
@@ -0,0 +1,19 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
|
||||
.header-pane {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
column-gap: var(--size-h);
|
||||
align-items: center;
|
||||
padding-bottom: var(--size);
|
||||
background-color: var(--background);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { KeysComponent } from './keys.component';
|
||||
|
||||
describe('KeysComponent', () => {
|
||||
let component: KeysComponent;
|
||||
let fixture: ComponentFixture<KeysComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [KeysComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(KeysComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import {
|
||||
NavComponent,
|
||||
NostrHelper,
|
||||
StorageService,
|
||||
ToastComponent,
|
||||
} from '@common';
|
||||
import { IconButtonComponent } from '../../../../../../common/src/lib/components/icon-button/icon-button.component';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
interface CustomIdentity {
|
||||
id: string;
|
||||
nick: string;
|
||||
privkeyNsec: string;
|
||||
privkeyHex: string;
|
||||
pubkeyNpub: string;
|
||||
pubkeyHex: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-keys',
|
||||
imports: [IconButtonComponent, FormsModule, ToastComponent],
|
||||
templateUrl: './keys.component.html',
|
||||
styleUrl: './keys.component.scss',
|
||||
})
|
||||
export class KeysComponent extends NavComponent implements OnInit {
|
||||
identity?: CustomIdentity;
|
||||
|
||||
readonly #activatedRoute = inject(ActivatedRoute);
|
||||
readonly #storage = inject(StorageService);
|
||||
|
||||
ngOnInit(): void {
|
||||
const identityId = this.#activatedRoute.parent?.snapshot.params['id'];
|
||||
if (!identityId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#initialize(identityId);
|
||||
}
|
||||
|
||||
copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
toggleType(element: HTMLInputElement) {
|
||||
if (element.type === 'password') {
|
||||
element.type = 'text';
|
||||
} else {
|
||||
element.type = 'password';
|
||||
}
|
||||
}
|
||||
|
||||
async #initialize(identityId: string) {
|
||||
const identity = this.#storage
|
||||
.getBrowserSessionHandler()
|
||||
.browserSessionData?.identities.find((x) => x.id === identityId);
|
||||
|
||||
if (!identity) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pubkey = NostrHelper.pubkeyFromPrivkey(identity.privkey);
|
||||
|
||||
this.identity = {
|
||||
id: identity.id,
|
||||
nick: identity.nick,
|
||||
privkeyHex: identity.privkey,
|
||||
privkeyNsec: NostrHelper.privkey2nsec(identity.privkey),
|
||||
pubkeyHex: pubkey,
|
||||
pubkeyNpub: NostrHelper.pubkey2npub(pubkey),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<div class="header-pane">
|
||||
<lib-icon-button icon="chevron-left" (click)="navigateBack()"></lib-icon-button>
|
||||
<span>Permissions</span>
|
||||
</div>
|
||||
|
||||
@if(hostsPermissions.length === 0) {
|
||||
<span class="text-muted" style="font-size: 12px">
|
||||
Nothing configured so far.
|
||||
</span>
|
||||
} @for(hostPermissions of hostsPermissions; track hostPermissions) {
|
||||
<div class="permissions-card">
|
||||
<span style="margin-bottom: 4px; font-weight: 500">
|
||||
{{ hostPermissions.host }}
|
||||
</span>
|
||||
|
||||
@for(permission of hostPermissions.permissions; track permission) {
|
||||
<div class="permission">
|
||||
<span
|
||||
[class.action-allow]="permission.methodPolicy === 'allow'"
|
||||
[class.action-deny]="permission.methodPolicy === 'deny'"
|
||||
>{{ permission.methodPolicy }}</span
|
||||
>
|
||||
<span class="text-muted">{{ permission.method }}</span>
|
||||
@if(typeof permission.kind !== 'undefined') {
|
||||
<span>(kind {{ permission.kind }})</span>
|
||||
}
|
||||
<div class="sam-flex-grow"></div>
|
||||
<lib-icon-button
|
||||
icon="trash"
|
||||
title="Revoke permission"
|
||||
(click)="onClickRevokePermission(permission)"
|
||||
></lib-icon-button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
|
||||
.header-pane {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
column-gap: var(--size-h);
|
||||
align-items: center;
|
||||
padding-bottom: var(--size);
|
||||
background-color: var(--background);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.permissions-card {
|
||||
background: var(--background-light);
|
||||
border-radius: 8px;
|
||||
padding: calc(var(--size) / 2) var(--size);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.permission {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
column-gap: var(--size);
|
||||
font-size: 12px;
|
||||
margin-left: -8px;
|
||||
padding-left: 8px;
|
||||
margin-right: -8px;
|
||||
padding-right: 8px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--background-light-hover);
|
||||
}
|
||||
|
||||
.action-allow {
|
||||
background: var(--bs-green);
|
||||
border-radius: 4px;
|
||||
padding: 0 4px;
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.action-deny {
|
||||
background: var(--bs-danger-border-subtle);
|
||||
border-radius: 4px;
|
||||
padding: 0 4px;
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { PermissionsComponent } from './permissions.component';
|
||||
|
||||
describe('PermissionsComponent', () => {
|
||||
let component: PermissionsComponent;
|
||||
let fixture: ComponentFixture<PermissionsComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PermissionsComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PermissionsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import {
|
||||
IconButtonComponent,
|
||||
Identity_DECRYPTED,
|
||||
NavComponent,
|
||||
Permission_DECRYPTED,
|
||||
StorageService,
|
||||
} from '@common';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
interface HostPermissions {
|
||||
host: string;
|
||||
permissions: Permission_DECRYPTED[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-permissions',
|
||||
imports: [IconButtonComponent],
|
||||
templateUrl: './permissions.component.html',
|
||||
styleUrl: './permissions.component.scss',
|
||||
})
|
||||
export class PermissionsComponent extends NavComponent implements OnInit {
|
||||
identity?: Identity_DECRYPTED;
|
||||
hostsPermissions: HostPermissions[] = [];
|
||||
|
||||
readonly #activatedRoute = inject(ActivatedRoute);
|
||||
readonly #storage = inject(StorageService);
|
||||
|
||||
ngOnInit(): void {
|
||||
const selectedIdentityId =
|
||||
this.#activatedRoute.parent?.snapshot.params['id'];
|
||||
if (!selectedIdentityId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#initialize(selectedIdentityId);
|
||||
}
|
||||
|
||||
async onClickRevokePermission(permission: Permission_DECRYPTED) {
|
||||
await this.#storage.deletePermission(permission.id);
|
||||
this.#buildHostsPermissions(this.identity?.id);
|
||||
}
|
||||
|
||||
#initialize(identityId: string) {
|
||||
this.identity = this.#storage
|
||||
.getBrowserSessionHandler()
|
||||
.browserSessionData?.identities.find((x) => x.id === identityId);
|
||||
|
||||
if (!this.identity) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#buildHostsPermissions(identityId);
|
||||
}
|
||||
|
||||
#buildHostsPermissions(identityId: string | undefined) {
|
||||
if (!identityId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hostsPermissions = [];
|
||||
|
||||
const hostPermissions = (
|
||||
this.#storage.getBrowserSessionHandler().browserSessionData
|
||||
?.permissions ?? []
|
||||
)
|
||||
.filter((x) => x.identityId === identityId)
|
||||
.sortBy((x) => x.host)
|
||||
.groupBy(
|
||||
(x) => x.host,
|
||||
(y) => y
|
||||
);
|
||||
|
||||
hostPermissions.forEach((permissions, host) => {
|
||||
this.hostsPermissions.push({
|
||||
host: host,
|
||||
permissions: permissions.sortBy((x) => x.method),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<!-- RELAY_TEMPLATE -->
|
||||
<ng-template #relayTemplate let-relay="relay">
|
||||
<div class="sam-flex-row gap relay">
|
||||
<div class="sam-flex-column sam-flex-grow">
|
||||
<span>{{ relay.url | visualRelay }}</span>
|
||||
<div class="sam-flex-row gap-h">
|
||||
<lib-relay-rw
|
||||
type="read"
|
||||
[(model)]="relay.read"
|
||||
(modelChange)="onRelayChanged(relay)"
|
||||
></lib-relay-rw>
|
||||
<lib-relay-rw
|
||||
type="write"
|
||||
[(model)]="relay.write"
|
||||
(modelChange)="onRelayChanged(relay)"
|
||||
></lib-relay-rw>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<lib-icon-button
|
||||
icon="trash"
|
||||
title="Remove relay"
|
||||
(click)="onClickRemoveRelay(relay)"
|
||||
style="margin-top: 4px"
|
||||
></lib-icon-button>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<div class="header-pane">
|
||||
<lib-icon-button
|
||||
icon="chevron-left"
|
||||
(click)="navigateBack()"
|
||||
></lib-icon-button>
|
||||
<span>Relays</span>
|
||||
</div>
|
||||
|
||||
<div class="sam-mb-2 sam-flex-row gap">
|
||||
<div class="sam-flex-column sam-flex-grow">
|
||||
<input
|
||||
type="text"
|
||||
(focus)="addRelayInputHasFocus = true"
|
||||
(blur)="addRelayInputHasFocus = false"
|
||||
[placeholder]="addRelayInputHasFocus ? 'server.com' : 'Add a relay'"
|
||||
class="form-control"
|
||||
[(ngModel)]="newRelay.url"
|
||||
(ngModelChange)="evaluateCanAdd()"
|
||||
/>
|
||||
<div class="sam-flex-row gap-h" style="margin-top: 4px">
|
||||
<lib-relay-rw
|
||||
class="sam-flex-grow"
|
||||
type="read"
|
||||
[(model)]="newRelay.read"
|
||||
(modelChange)="evaluateCanAdd()"
|
||||
></lib-relay-rw>
|
||||
<lib-relay-rw
|
||||
class="sam-flex-grow"
|
||||
type="write"
|
||||
[(model)]="newRelay.write"
|
||||
(modelChange)="evaluateCanAdd()"
|
||||
></lib-relay-rw>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
style="height: 100%"
|
||||
(click)="onClickAddRelay()"
|
||||
[disabled]="!canAdd"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@for(relay of relays; track relay) {
|
||||
<ng-container
|
||||
*ngTemplateOutlet="relayTemplate; context: { relay: relay }"
|
||||
></ng-container>
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
|
||||
.header-pane {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
column-gap: var(--size-h);
|
||||
align-items: center;
|
||||
padding-bottom: var(--size);
|
||||
background-color: var(--background);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.relay {
|
||||
margin-bottom: 4px;
|
||||
padding: 4px 8px 6px 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--background-light);
|
||||
|
||||
&:hover {
|
||||
background: var(--background-light-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { RelaysComponent } from './relays.component';
|
||||
|
||||
describe('RelaysComponent', () => {
|
||||
let component: RelaysComponent;
|
||||
let fixture: ComponentFixture<RelaysComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [RelaysComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(RelaysComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import {
|
||||
IconButtonComponent,
|
||||
Identity_DECRYPTED,
|
||||
NavComponent,
|
||||
Relay_DECRYPTED,
|
||||
RelayRwComponent,
|
||||
StorageService,
|
||||
VisualRelayPipe,
|
||||
} from '@common';
|
||||
|
||||
interface NewRelay {
|
||||
url: string;
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-relays',
|
||||
imports: [
|
||||
IconButtonComponent,
|
||||
FormsModule,
|
||||
RelayRwComponent,
|
||||
NgTemplateOutlet,
|
||||
VisualRelayPipe,
|
||||
],
|
||||
templateUrl: './relays.component.html',
|
||||
styleUrl: './relays.component.scss',
|
||||
})
|
||||
export class RelaysComponent extends NavComponent implements OnInit {
|
||||
identity?: Identity_DECRYPTED;
|
||||
relays: Relay_DECRYPTED[] = [];
|
||||
addRelayInputHasFocus = false;
|
||||
newRelay: NewRelay = {
|
||||
url: '',
|
||||
read: true,
|
||||
write: true,
|
||||
};
|
||||
canAdd = false;
|
||||
|
||||
readonly #activatedRoute = inject(ActivatedRoute);
|
||||
readonly #storage = inject(StorageService);
|
||||
|
||||
ngOnInit(): void {
|
||||
const selectedIdentityId =
|
||||
this.#activatedRoute.parent?.snapshot.params['id'];
|
||||
if (!selectedIdentityId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#loadData(selectedIdentityId);
|
||||
}
|
||||
|
||||
evaluateCanAdd() {
|
||||
let canAdd = true;
|
||||
|
||||
if (!this.newRelay.url) {
|
||||
canAdd = false;
|
||||
} else if (!this.newRelay.read && !this.newRelay.write) {
|
||||
canAdd = false;
|
||||
}
|
||||
|
||||
this.canAdd = canAdd;
|
||||
}
|
||||
|
||||
async onClickRemoveRelay(relay: Relay_DECRYPTED) {
|
||||
if (!this.identity) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.#storage.deleteRelay(relay.id);
|
||||
this.#loadData(this.identity.id);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
|
||||
async onClickAddRelay() {
|
||||
if (!this.identity) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.#storage.addRelay({
|
||||
identityId: this.identity.id,
|
||||
url: 'wss://' + this.newRelay.url.toLowerCase(),
|
||||
read: this.newRelay.read,
|
||||
write: this.newRelay.write,
|
||||
});
|
||||
|
||||
this.newRelay = {
|
||||
url: '',
|
||||
read: true,
|
||||
write: true,
|
||||
};
|
||||
this.evaluateCanAdd();
|
||||
this.#loadData(this.identity.id);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
|
||||
async onRelayChanged(relay: Relay_DECRYPTED) {
|
||||
try {
|
||||
await this.#storage.updateRelay(relay);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
|
||||
#loadData(identityId: string) {
|
||||
this.identity = this.#storage
|
||||
.getBrowserSessionHandler()
|
||||
.browserSessionData?.identities.find((x) => x.id === identityId);
|
||||
|
||||
const relays: Relay_DECRYPTED[] = [];
|
||||
(this.#storage.getBrowserSessionHandler().browserSessionData?.relays ?? [])
|
||||
.filter((x) => x.identityId === identityId)
|
||||
.forEach((x) => {
|
||||
relays.push(JSON.parse(JSON.stringify(x)));
|
||||
});
|
||||
this.relays = relays;
|
||||
}
|
||||
}
|
||||
36
projects/chrome/src/app/components/home/home.component.html
Normal file
36
projects/chrome/src/app/components/home/home.component.html
Normal file
@@ -0,0 +1,36 @@
|
||||
<div class="tab-content">
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<a
|
||||
class="tab"
|
||||
routerLink="/home/identity"
|
||||
routerLinkActive="active"
|
||||
title="Your selected identity"
|
||||
>
|
||||
<i class="bi bi-person-circle"></i>
|
||||
</a>
|
||||
|
||||
<a
|
||||
class="tab"
|
||||
routerLink="/home/identities"
|
||||
routerLinkActive="active"
|
||||
title="Identities"
|
||||
>
|
||||
<i class="bi bi-people-fill"></i>
|
||||
</a>
|
||||
|
||||
<a
|
||||
class="tab"
|
||||
routerLink="/home/settings"
|
||||
routerLinkActive="active"
|
||||
title="Settings"
|
||||
>
|
||||
<i class="bi bi-gear"></i>
|
||||
</a>
|
||||
|
||||
<a class="tab" routerLink="/home/info" routerLinkActive="active" title="Info">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
43
projects/chrome/src/app/components/home/home.component.scss
Normal file
43
projects/chrome/src/app/components/home/home.component.scss
Normal file
@@ -0,0 +1,43 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.tab-content {
|
||||
height: calc(100% - 60px);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
height: 60px;
|
||||
min-height: 60px;
|
||||
background: var(--background-light);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
a {
|
||||
all: unset;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
|
||||
color: gray;
|
||||
border-top: 3px solid transparent;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--background-light-hover);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #ffffff;
|
||||
border-top: 3px solid #0d6efd;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { HomeComponent } from './home.component';
|
||||
|
||||
describe('HomeComponent', () => {
|
||||
let component: HomeComponent;
|
||||
let fixture: ComponentFixture<HomeComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [HomeComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(HomeComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
10
projects/chrome/src/app/components/home/home.component.ts
Normal file
10
projects/chrome/src/app/components/home/home.component.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterModule, RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
templateUrl: './home.component.html',
|
||||
styleUrl: './home.component.scss',
|
||||
imports: [RouterOutlet, RouterModule],
|
||||
})
|
||||
export class HomeComponent {}
|
||||
@@ -0,0 +1,78 @@
|
||||
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
|
||||
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
|
||||
<div class="custom-header" style="position: sticky; top: 0">
|
||||
<span class="text">Identities </span>
|
||||
|
||||
<button class="button btn btn-primary btn-sm" (click)="onClickNewIdentity()">
|
||||
<div class="sam-flex-row gap-h">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
<span>New</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@let sessionData = storage.getBrowserSessionHandler().browserSessionData;
|
||||
<!-- - -->
|
||||
@let identities = sessionData?.identities ?? []; @if(identities.length === 0) {
|
||||
<div
|
||||
style="
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
<span class="sam-text-muted">
|
||||
Create your first identity by clicking on the button in the upper right
|
||||
corner.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
} @for(identity of identities; track identity) {
|
||||
<div
|
||||
class="identity"
|
||||
style="overflow: hidden"
|
||||
(click)="onClickEditIdentity(identity)"
|
||||
>
|
||||
@let isSelected = identity.id === sessionData?.selectedIdentityId;
|
||||
|
||||
<span
|
||||
class="no-select"
|
||||
style="overflow-x: hidden; text-overflow: ellipsis; white-space: nowrap"
|
||||
[class.not-active]="!isSelected"
|
||||
>
|
||||
{{ identity.nick }}
|
||||
</span>
|
||||
|
||||
<div class="sam-flex-grow"></div>
|
||||
|
||||
@if(isSelected) {
|
||||
<lib-icon-button
|
||||
icon="star-fill"
|
||||
title="Edit identity"
|
||||
style="pointer-events: none; color: var(--bs-pink)"
|
||||
></lib-icon-button>
|
||||
}
|
||||
|
||||
<div class="buttons sam-flex-row gap-h">
|
||||
@if(!isSelected) {
|
||||
<lib-icon-button
|
||||
icon="star-fill"
|
||||
title="Select identity"
|
||||
(click)="
|
||||
onClickSwitchIdentity(identity.id, $event);
|
||||
toast.show('Identity changed')
|
||||
"
|
||||
></lib-icon-button>
|
||||
}
|
||||
</div>
|
||||
<lib-icon-button
|
||||
icon="arrow-right"
|
||||
title="Edit identity"
|
||||
style="pointer-events: none"
|
||||
></lib-icon-button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<lib-toast #toast></lib-toast>
|
||||
@@ -0,0 +1,68 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
|
||||
.custom-header {
|
||||
padding-top: var(--size);
|
||||
padding-bottom: var(--size);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto;
|
||||
align-items: center;
|
||||
background: var(--background);
|
||||
|
||||
.button {
|
||||
grid-column-start: 1;
|
||||
grid-column-end: 2;
|
||||
grid-row-start: 1;
|
||||
grid-row-end: 2;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.text {
|
||||
grid-column-start: 1;
|
||||
grid-column-end: 2;
|
||||
grid-row-start: 1;
|
||||
grid-row-end: 2;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
justify-self: center;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.identity {
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding-left: 16px;
|
||||
padding-right: 8px;
|
||||
background: var(--background-light);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
|
||||
.not-active {
|
||||
//color: #525b6a;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--background-light-hover);
|
||||
|
||||
.buttons {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { IdentitiesComponent } from './identities.component';
|
||||
|
||||
describe('IdentitiesComponent', () => {
|
||||
let component: IdentitiesComponent;
|
||||
let fixture: ComponentFixture<IdentitiesComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [IdentitiesComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(IdentitiesComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
IconButtonComponent,
|
||||
Identity_DECRYPTED,
|
||||
StorageService,
|
||||
ToastComponent,
|
||||
} from '@common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-identities',
|
||||
templateUrl: './identities.component.html',
|
||||
styleUrl: './identities.component.scss',
|
||||
imports: [IconButtonComponent, ToastComponent],
|
||||
})
|
||||
export class IdentitiesComponent {
|
||||
readonly storage = inject(StorageService);
|
||||
|
||||
readonly #router = inject(Router);
|
||||
|
||||
onClickNewIdentity() {
|
||||
this.#router.navigateByUrl('/new-identity');
|
||||
}
|
||||
|
||||
onClickEditIdentity(identity: Identity_DECRYPTED) {
|
||||
this.#router.navigateByUrl(`/edit-identity/${identity.id}/home`);
|
||||
}
|
||||
|
||||
async onClickSwitchIdentity(identityId: string, event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
await this.storage.switchIdentity(identityId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
|
||||
<div class="sam-text-header">
|
||||
<span>You</span>
|
||||
</div>
|
||||
|
||||
<div class="vertically-centered">
|
||||
<div class="sam-flex-column center">
|
||||
<div class="sam-flex-column gap center">
|
||||
<div class="picture-frame" [class.padding]="!loadedData.profile?.image">
|
||||
<img
|
||||
[src]="
|
||||
!loadedData.profile?.image
|
||||
? 'person-fill.svg'
|
||||
: loadedData.profile?.image
|
||||
"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->
|
||||
<span class="name" (click)="onClickShowDetails()">
|
||||
{{ selectedIdentity?.nick }}
|
||||
</span>
|
||||
|
||||
@if(loadedData.profile) {
|
||||
<div class="sam-flex-row gap-h">
|
||||
@if(loadedData.validating) {
|
||||
<i class="bi bi-circle color-activity"></i>
|
||||
} @else { @if(loadedData.nip05isValidated) {
|
||||
<i class="bi bi-patch-check sam-color-primary"></i>
|
||||
} @else {
|
||||
<i class="bi bi-exclamation-octagon-fill sam-color-danger"></i>
|
||||
} }
|
||||
|
||||
<span class="sam-color-primary">{{
|
||||
loadedData.profile.nip05 | visualNip05
|
||||
}}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<span> </span>
|
||||
}
|
||||
|
||||
<lib-pubkey
|
||||
[value]="selectedIdentityNpub ?? 'na'"
|
||||
[first]="14"
|
||||
[last]="8"
|
||||
(click)="
|
||||
copyToClipboard(selectedIdentityNpub);
|
||||
toast.show('Copied to clipboard')
|
||||
"
|
||||
></lib-pubkey>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<lib-toast #toast></lib-toast>
|
||||
@@ -0,0 +1,41 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.vertically-centered {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
max-width: 343px;
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.picture-frame {
|
||||
height: 120px;
|
||||
width: 120px;
|
||||
border: 2px solid white;
|
||||
border-radius: 100%;
|
||||
&.padding {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
img {
|
||||
border-radius: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.color-activity {
|
||||
color: var(--bs-border-color);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { IdentityComponent } from './identity.component';
|
||||
|
||||
describe('IdentityComponent', () => {
|
||||
let component: IdentityComponent;
|
||||
let fixture: ComponentFixture<IdentityComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [IdentityComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(IdentityComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import {
|
||||
Identity_DECRYPTED,
|
||||
NostrHelper,
|
||||
PubkeyComponent,
|
||||
StorageService,
|
||||
ToastComponent,
|
||||
VisualNip05Pipe,
|
||||
} from '@common';
|
||||
import NDK, { NDKUserProfile } from '@nostr-dev-kit/ndk';
|
||||
|
||||
interface LoadedData {
|
||||
profile: NDKUserProfile | undefined;
|
||||
nip05: string | undefined;
|
||||
nip05isValidated: boolean | undefined;
|
||||
validating: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-identity',
|
||||
imports: [PubkeyComponent, VisualNip05Pipe, ToastComponent],
|
||||
templateUrl: './identity.component.html',
|
||||
styleUrl: './identity.component.scss',
|
||||
})
|
||||
export class IdentityComponent implements OnInit {
|
||||
selectedIdentity: Identity_DECRYPTED | undefined;
|
||||
selectedIdentityNpub: string | undefined;
|
||||
loadedData: LoadedData = {
|
||||
profile: undefined,
|
||||
nip05: undefined,
|
||||
nip05isValidated: undefined,
|
||||
validating: false,
|
||||
};
|
||||
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.#loadData();
|
||||
}
|
||||
|
||||
copyToClipboard(pubkey: string | undefined) {
|
||||
if (!pubkey) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(pubkey);
|
||||
}
|
||||
|
||||
onClickShowDetails() {
|
||||
if (!this.selectedIdentity) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#router.navigateByUrl(
|
||||
`/edit-identity/${this.selectedIdentity.id}/home`
|
||||
);
|
||||
}
|
||||
|
||||
async #loadData() {
|
||||
try {
|
||||
const selectedIdentityId =
|
||||
this.#storage.getBrowserSessionHandler().browserSessionData
|
||||
?.selectedIdentityId ?? null;
|
||||
|
||||
const identity = this.#storage
|
||||
.getBrowserSessionHandler()
|
||||
.browserSessionData?.identities.find(
|
||||
(x) => x.id === selectedIdentityId
|
||||
);
|
||||
|
||||
if (!identity) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedIdentity = identity;
|
||||
const pubkey = NostrHelper.pubkeyFromPrivkey(identity.privkey);
|
||||
this.selectedIdentityNpub = NostrHelper.pubkey2npub(pubkey);
|
||||
|
||||
// Determine the user's relays to check for his profile.
|
||||
const relays =
|
||||
this.#storage
|
||||
.getBrowserSessionHandler()
|
||||
.browserSessionData?.relays.filter(
|
||||
(x) => x.identityId === identity.id
|
||||
) ?? [];
|
||||
if (relays.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const relevantRelays = relays.filter((x) => x.write).map((x) => x.url);
|
||||
|
||||
// Fetch the user's profile.
|
||||
const ndk = new NDK({
|
||||
explicitRelayUrls: relevantRelays,
|
||||
});
|
||||
|
||||
await ndk.connect();
|
||||
|
||||
const user = ndk.getUser({
|
||||
pubkey: NostrHelper.pubkeyFromPrivkey(identity.privkey),
|
||||
//relayUrls: relevantRelays,
|
||||
});
|
||||
this.loadedData.profile = (await user.fetchProfile()) ?? undefined;
|
||||
if (this.loadedData.profile?.nip05) {
|
||||
this.loadedData.validating = true;
|
||||
this.loadedData.nip05isValidated =
|
||||
(await user.validateNip05(this.loadedData.profile.nip05)) ??
|
||||
undefined;
|
||||
this.loadedData.validating = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<div class="sam-text-header">
|
||||
<span> Gooti </span>
|
||||
</div>
|
||||
|
||||
<span>Version {{ version }}</span>
|
||||
|
||||
<br />
|
||||
|
||||
<span> Website </span>
|
||||
<a href="https://getgooti.com" target="_blank">www.getgooti.com</a>
|
||||
|
||||
<br />
|
||||
|
||||
<span> Source code</span>
|
||||
<a
|
||||
href="https://github.com/sam-hayes-org/gooti-extension"
|
||||
target="_blank"
|
||||
>
|
||||
github.com/sam-hayes-org/gooti-extension
|
||||
</a>
|
||||
|
||||
<div class="sam-flex-grow"></div>
|
||||
|
||||
<div class="sam-card sam-mb" style="align-items: center">
|
||||
<span>
|
||||
Made with <i class="bi bi-heart-fill" style="color: red"></i> by
|
||||
<a href="https://sam-hayes.org" target="_blank">Sam Hayes</a>
|
||||
</span>
|
||||
|
||||
<lib-pubkey
|
||||
class="sam-mt-h"
|
||||
value="npub1tgyjshvelwj73t3jy0n3xllgt03elkapfl3k3n0x2wkunegkgrwssfp0u4"
|
||||
(click)="toast.show('Copied to clipboard')"
|
||||
></lib-pubkey>
|
||||
</div>
|
||||
|
||||
<lib-toast #toast [bottom]="188"></lib-toast>
|
||||
@@ -0,0 +1,9 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
overflow-y: auto;
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { InfoComponent } from './info.component';
|
||||
|
||||
describe('InfoComponent', () => {
|
||||
let component: InfoComponent;
|
||||
let fixture: ComponentFixture<InfoComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [InfoComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(InfoComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { PubkeyComponent, ToastComponent } from '@common';
|
||||
import packageJson from '../../../../../../../package.json';
|
||||
|
||||
@Component({
|
||||
selector: 'app-info',
|
||||
imports: [PubkeyComponent, ToastComponent],
|
||||
templateUrl: './info.component.html',
|
||||
styleUrl: './info.component.scss',
|
||||
})
|
||||
export class InfoComponent {
|
||||
version = packageJson.custom.chrome.version;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<div class="sam-text-header">
|
||||
<span> Settings </span>
|
||||
</div>
|
||||
|
||||
<span>SYNC: {{ syncFlow }}</span>
|
||||
|
||||
<button class="btn btn-primary" (click)="onClickExportVault()">
|
||||
Export Vault
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
(click)="
|
||||
confirm.show(
|
||||
'Do you really want to import a vault? All existing data will be overwritten.',
|
||||
onImportVault.bind(fileInput)
|
||||
)
|
||||
"
|
||||
>
|
||||
Import Vault
|
||||
</button>
|
||||
|
||||
<div class="sam-flex-grow"></div>
|
||||
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
(click)="
|
||||
confirm.show(
|
||||
'Do you really want to delete your vault with all identities?',
|
||||
onDeleteVault.bind(this)
|
||||
)
|
||||
"
|
||||
>
|
||||
Delete Vault
|
||||
</button>
|
||||
|
||||
<lib-confirm #confirm> </lib-confirm>
|
||||
|
||||
<input
|
||||
#fileInput
|
||||
class="file-input"
|
||||
type="file"
|
||||
(change)="onImportFileChange($event)"
|
||||
accept=".json"
|
||||
/>
|
||||
@@ -0,0 +1,14 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: var(--size);
|
||||
overflow-y: auto;
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
|
||||
.file-input {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { SettingsComponent } from './settings.component';
|
||||
|
||||
describe('SettingsComponent', () => {
|
||||
let component: SettingsComponent;
|
||||
let fixture: ComponentFixture<SettingsComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SettingsComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SettingsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import {
|
||||
BrowserSyncData,
|
||||
BrowserSyncFlow,
|
||||
ConfirmComponent,
|
||||
DateHelper,
|
||||
StorageService,
|
||||
} from '@common';
|
||||
import { StartupService } from '../../../services/startup/startup.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
imports: [ConfirmComponent],
|
||||
templateUrl: './settings.component.html',
|
||||
styleUrl: './settings.component.scss',
|
||||
})
|
||||
export class SettingsComponent implements OnInit {
|
||||
syncFlow: string | undefined;
|
||||
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #startup = inject(StartupService);
|
||||
|
||||
ngOnInit(): void {
|
||||
const vault = JSON.stringify(
|
||||
this.#storage.getBrowserSyncHandler().browserSyncData
|
||||
);
|
||||
console.log(vault.length / 1024 + ' KB');
|
||||
|
||||
switch (this.#storage.getGootiMetaHandler().gootiMetaData?.syncFlow) {
|
||||
case BrowserSyncFlow.NO_SYNC:
|
||||
this.syncFlow = 'Off';
|
||||
break;
|
||||
|
||||
case BrowserSyncFlow.BROWSER_SYNC:
|
||||
this.syncFlow = 'Google Chrome';
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async onDeleteVault() {
|
||||
try {
|
||||
await this.#storage.deleteVault();
|
||||
this.#startup.startOver();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
|
||||
onImportVault() {
|
||||
(this as unknown as HTMLInputElement).click();
|
||||
}
|
||||
|
||||
async onImportFileChange(event: Event) {
|
||||
try {
|
||||
const element = event.currentTarget as HTMLInputElement;
|
||||
const file = element.files !== null ? element.files[0] : undefined;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await file.text();
|
||||
const vault = JSON.parse(text) as BrowserSyncData;
|
||||
|
||||
await this.#storage.deleteVault(true);
|
||||
await this.#storage.importVault(vault);
|
||||
this.#storage.isInitialized = false;
|
||||
this.#startup.startOver();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
|
||||
async onClickExportVault() {
|
||||
const jsonVault = this.#storage.exportVault();
|
||||
|
||||
const dateTimeString = DateHelper.dateToISOLikeButLocal(new Date());
|
||||
const fileName = `Gooti Chrome - Vault Export - ${dateTimeString}.json`;
|
||||
|
||||
this.#downloadJson(jsonVault, fileName);
|
||||
}
|
||||
|
||||
#downloadJson(jsonString: string, fileName: string) {
|
||||
const dataStr =
|
||||
'data:text/json;charset=utf-8,' + encodeURIComponent(jsonString);
|
||||
const downloadAnchorNode = document.createElement('a');
|
||||
downloadAnchorNode.setAttribute('href', dataStr);
|
||||
downloadAnchorNode.setAttribute('download', fileName);
|
||||
document.body.appendChild(downloadAnchorNode);
|
||||
downloadAnchorNode.click();
|
||||
downloadAnchorNode.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<div class="sam-text-header">
|
||||
<span>New Identity</span>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<input
|
||||
id="nickElement"
|
||||
type="text"
|
||||
placeholder="Nick"
|
||||
class="form-control form-control-lg"
|
||||
style="font-size: 1rem"
|
||||
[(ngModel)]="identity.nick"
|
||||
autocomplete="off"
|
||||
(ngModelChange)="validateCanSave()"
|
||||
/>
|
||||
|
||||
<div class="sam-mt input-group mb-3">
|
||||
<input
|
||||
id="privkeyInputElement"
|
||||
#privkeyInputElement
|
||||
type="password"
|
||||
placeholder="Private Key (HEX or NSEC)"
|
||||
class="form-control form-control-lg"
|
||||
style="font-size: 1rem"
|
||||
[(ngModel)]="identity.privkeyInput"
|
||||
autocomplete="off"
|
||||
(ngModelChange)="validateCanSave()"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
type="button"
|
||||
(click)="toggleType(privkeyInputElement)"
|
||||
>
|
||||
<i
|
||||
class="bi bi-eye"
|
||||
[class.bi-eye]="privkeyInputElement.type === 'password'"
|
||||
[class.bi-eye-slash]="privkeyInputElement.type === 'text'"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="sam-mt"
|
||||
(click)="onClickGeneratePrivkey()"
|
||||
type="button"
|
||||
class="btn btn-link"
|
||||
>
|
||||
Generate private key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sam-footer-grid-2">
|
||||
<button type="button" class="btn btn-secondary" (click)="navigateBack()">
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
[disabled]="!canSave"
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
(click)="onClickSave()"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!----------->
|
||||
<!-- ALERT -->
|
||||
<!----------->
|
||||
@if(alertMessage) {
|
||||
<div
|
||||
style="
|
||||
position: absolute;
|
||||
bottom: 60px;
|
||||
align-self: center;
|
||||
margin-left: 16px;
|
||||
margin-right: 16px;
|
||||
"
|
||||
>
|
||||
<div class="alert alert-danger sam-flex-row gap" role="alert">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
<span>{{ alertMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.content {
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { NewIdentityComponent } from './new-identity.component';
|
||||
|
||||
describe('NewIdentityComponent', () => {
|
||||
let component: NewIdentityComponent;
|
||||
let fixture: ComponentFixture<NewIdentityComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NewIdentityComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(NewIdentityComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { AfterViewInit, Component, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { NavComponent, NostrHelper, StorageService } from '@common';
|
||||
import { generateSecretKey } from 'nostr-tools';
|
||||
import { bytesToHex } from '@noble/hashes/utils';
|
||||
|
||||
@Component({
|
||||
selector: 'app-new-identity',
|
||||
templateUrl: './new-identity.component.html',
|
||||
styleUrl: './new-identity.component.scss',
|
||||
imports: [FormsModule],
|
||||
})
|
||||
export class NewIdentityComponent
|
||||
extends NavComponent
|
||||
implements AfterViewInit
|
||||
{
|
||||
readonly identity = {
|
||||
nick: '',
|
||||
privkeyInput: '',
|
||||
};
|
||||
canSave = false;
|
||||
alertMessage: any | undefined;
|
||||
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
document.getElementById('nickElement')?.focus();
|
||||
}
|
||||
|
||||
toggleType(element: HTMLInputElement) {
|
||||
if (element.type === 'password') {
|
||||
element.type = 'text';
|
||||
} else {
|
||||
element.type = 'password';
|
||||
}
|
||||
}
|
||||
|
||||
onClickGeneratePrivkey() {
|
||||
const sk = generateSecretKey();
|
||||
const privkey = bytesToHex(sk);
|
||||
|
||||
this.identity.privkeyInput = NostrHelper.privkey2nsec(privkey);
|
||||
this.validateCanSave();
|
||||
}
|
||||
|
||||
validateCanSave() {
|
||||
if (!this.identity.nick || !this.identity.privkeyInput) {
|
||||
this.canSave = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
NostrHelper.getNostrPrivkeyObject(
|
||||
this.identity.privkeyInput.toLocaleLowerCase()
|
||||
);
|
||||
this.canSave = true;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
this.canSave = false;
|
||||
}
|
||||
}
|
||||
|
||||
async onClickSave() {
|
||||
if (!this.canSave) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.identity.nick || !this.identity.privkeyInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.#storage.addIdentity({
|
||||
nick: this.identity.nick,
|
||||
privkeyString: this.identity.privkeyInput,
|
||||
});
|
||||
this.#router.navigateByUrl('/home/identities');
|
||||
} catch (error: any) {
|
||||
this.alertMessage = error?.message;
|
||||
setTimeout(() => {
|
||||
this.alertMessage = undefined;
|
||||
}, 4500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<div class="sam-text-header">
|
||||
<span>Gooti</span>
|
||||
</div>
|
||||
|
||||
<div class="vertically-centered">
|
||||
<div class="sam-flex-column center">
|
||||
<div class="sam-flex-column gap" style="align-items: center">
|
||||
<div class="logo-frame">
|
||||
<img src="gooti.svg" height="120" width="120" alt=""/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="sam-mt-2 btn btn-primary"
|
||||
(click)="router.navigateByUrl('/vault-create/new')"
|
||||
>
|
||||
<div class="sam-flex-row gap-h">
|
||||
<i class="bi bi-plus-circle" style="height: 22px"></i>
|
||||
<span>Create a new vault</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<span class="sam-text-muted">or</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
(click)="fileInput.click()"
|
||||
>
|
||||
<span>Import a vault</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
#fileInput
|
||||
class="file-input"
|
||||
type="file"
|
||||
(change)="onImportFileChange($event)"
|
||||
accept=".json"
|
||||
/>
|
||||
@@ -0,0 +1,17 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.vertically-centered {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { HomeComponent } from './home.component';
|
||||
|
||||
describe('HomeComponent', () => {
|
||||
let component: HomeComponent;
|
||||
let fixture: ComponentFixture<HomeComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [HomeComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(HomeComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { BrowserSyncData, StorageService } from '@common';
|
||||
import { StartupService } from '../../../services/startup/startup.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
imports: [],
|
||||
templateUrl: './home.component.html',
|
||||
styleUrl: './home.component.scss',
|
||||
})
|
||||
export class HomeComponent {
|
||||
readonly router = inject(Router);
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #startup = inject(StartupService);
|
||||
|
||||
async onImportFileChange(event: Event) {
|
||||
try {
|
||||
const element = event.currentTarget as HTMLInputElement;
|
||||
const file = element.files !== null ? element.files[0] : undefined;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await file.text();
|
||||
const vault = JSON.parse(text) as BrowserSyncData;
|
||||
console.log(vault);
|
||||
|
||||
await this.#storage.importVault(vault);
|
||||
this.#startup.startOver();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<div class="sam-text-header">
|
||||
<span>Gooti</span>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="sam-flex-column gap" style="align-items: center">
|
||||
<div class="logo-frame">
|
||||
<img src="gooti.svg" height="120" width="120" alt=""/>
|
||||
</div>
|
||||
|
||||
<span class="sam-mt-2"> Please define a password for your vault. </span>
|
||||
|
||||
<small class="sam-text-muted">
|
||||
Sensitive data is encypted before it is stored.
|
||||
</small>
|
||||
|
||||
<div class="sam-mt input-group">
|
||||
<input
|
||||
#passwordInputElement
|
||||
type="password"
|
||||
class="form-control form-control-lg"
|
||||
style="font-size: 1rem"
|
||||
placeholder="vault password"
|
||||
[(ngModel)]="password"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
type="button"
|
||||
(click)="toggleType(passwordInputElement)"
|
||||
>
|
||||
<i
|
||||
class="bi bi-eye"
|
||||
[class.bi-eye]="passwordInputElement.type === 'password'"
|
||||
[class.bi-eye-slash]="passwordInputElement.type === 'text'"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
[disabled]="!password || password.length < 4"
|
||||
type="button"
|
||||
class="sam-mt btn btn-primary"
|
||||
(click)="createVault()"
|
||||
>
|
||||
Create vault
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,48 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
color-scheme: dark;
|
||||
|
||||
.custom-header {
|
||||
padding-top: var(--size);
|
||||
padding-bottom: var(--size);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto;
|
||||
align-items: center;
|
||||
|
||||
.back {
|
||||
grid-column-start: 1;
|
||||
grid-column-end: 2;
|
||||
grid-row-start: 1;
|
||||
grid-row-end: 2;
|
||||
justify-self: start;
|
||||
padding-left: var(--size);
|
||||
}
|
||||
|
||||
.text {
|
||||
grid-column-start: 1;
|
||||
grid-column-end: 2;
|
||||
grid-row-start: 1;
|
||||
grid-row-end: 2;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
justify-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
padding: 0 var(--size) var(--size) var(--size);
|
||||
}
|
||||
|
||||
.logo-frame {
|
||||
border: 2px solid #0d6efd;
|
||||
border-radius: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { NewComponent } from './new.component';
|
||||
|
||||
describe('NewComponent', () => {
|
||||
let component: NewComponent;
|
||||
let fixture: ComponentFixture<NewComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NewComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(NewComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { NavComponent, StorageService } from '@common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-new',
|
||||
imports: [FormsModule],
|
||||
templateUrl: './new.component.html',
|
||||
styleUrl: './new.component.scss',
|
||||
})
|
||||
export class NewComponent extends NavComponent {
|
||||
password = '';
|
||||
|
||||
readonly #router = inject(Router);
|
||||
readonly #storage = inject(StorageService);
|
||||
|
||||
toggleType(element: HTMLInputElement) {
|
||||
if (element.type === 'password') {
|
||||
element.type = 'text';
|
||||
} else {
|
||||
element.type = 'password';
|
||||
}
|
||||
}
|
||||
|
||||
async createVault() {
|
||||
if (!this.password) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.#storage.createNewVault(this.password);
|
||||
this.#router.navigateByUrl('/home/identities');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<router-outlet></router-outlet>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { VaultCreateComponent } from './vault-create.component';
|
||||
|
||||
describe('VaultCreateComponent', () => {
|
||||
let component: VaultCreateComponent;
|
||||
let fixture: ComponentFixture<VaultCreateComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VaultCreateComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(VaultCreateComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-vault-create',
|
||||
imports: [RouterOutlet],
|
||||
templateUrl: './vault-create.component.html',
|
||||
styleUrl: './vault-create.component.scss'
|
||||
})
|
||||
export class VaultCreateComponent {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<div class="sam-text-header">
|
||||
<span class="brand">Gooti</span>
|
||||
</div>
|
||||
|
||||
<div class="content-login-vault">
|
||||
<div class="sam-flex-column gap" style="align-items: center">
|
||||
<div class="logo-frame">
|
||||
<img src="gooti.svg" height="120" width="120" alt=""/>
|
||||
</div>
|
||||
|
||||
<div class="sam-mt-2 input-group">
|
||||
<input
|
||||
#passwordInputElement
|
||||
type="password"
|
||||
class="form-control"
|
||||
placeholder="vault password"
|
||||
[(ngModel)]="loginPassword"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
type="button"
|
||||
(click)="toggleType(passwordInputElement)"
|
||||
>
|
||||
<i
|
||||
class="bi bi-eye"
|
||||
[class.bi-eye]="passwordInputElement.type === 'password'"
|
||||
[class.bi-eye-slash]="passwordInputElement.type === 'text'"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
[disabled]="!loginPassword"
|
||||
type="button"
|
||||
class="sam-mt btn btn-primary"
|
||||
(click)="loginVault()"
|
||||
>
|
||||
<div class="sam-flex-row gap-h">
|
||||
<i class="bi bi-box-arrow-in-right"></i>
|
||||
<span>Sign in</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="sam-mt"
|
||||
(click)="
|
||||
confirm.show(
|
||||
'Do you really want to delete your vault? All existing data will be lost.',
|
||||
onClickDeleteVault.bind(this)
|
||||
)
|
||||
"
|
||||
type="button"
|
||||
class="btn btn-link"
|
||||
>
|
||||
Delete Vault
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!----------->
|
||||
<!-- ALERT -->
|
||||
<!----------->
|
||||
@if(showInvalidPasswordAlert) {
|
||||
<div style="position: absolute; bottom: 0; align-self: center">
|
||||
<div class="alert alert-danger sam-flex-row gap" role="alert">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
<span>Invalid password</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<lib-confirm #confirm> </lib-confirm>
|
||||
@@ -0,0 +1,19 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-items: center;
|
||||
|
||||
.logo-frame {
|
||||
border: 2px solid #0d6efd;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.content-login-vault {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
padding: 0 var(--size) var(--size) var(--size);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { VaultLoginComponent } from './vault-login.component';
|
||||
|
||||
describe('VaultLoginComponent', () => {
|
||||
let component: VaultLoginComponent;
|
||||
let fixture: ComponentFixture<VaultLoginComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VaultLoginComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(VaultLoginComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { ConfirmComponent, StorageService } from '@common';
|
||||
import { StartupService } from '../../services/startup/startup.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-vault-login',
|
||||
templateUrl: './vault-login.component.html',
|
||||
styleUrl: './vault-login.component.scss',
|
||||
imports: [FormsModule, ConfirmComponent],
|
||||
})
|
||||
export class VaultLoginComponent {
|
||||
loginPassword = '';
|
||||
showInvalidPasswordAlert = false;
|
||||
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
readonly #startup = inject(StartupService);
|
||||
|
||||
toggleType(element: HTMLInputElement) {
|
||||
if (element.type === 'password') {
|
||||
element.type = 'text';
|
||||
} else {
|
||||
element.type = 'password';
|
||||
}
|
||||
}
|
||||
|
||||
async loginVault() {
|
||||
if (!this.loginPassword) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.#storage.unlockVault(this.loginPassword);
|
||||
this.#router.navigateByUrl('/home/identities');
|
||||
} catch (error) {
|
||||
this.showInvalidPasswordAlert = true;
|
||||
console.log(error);
|
||||
window.setTimeout(() => {
|
||||
this.showInvalidPasswordAlert = false;
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
async onClickDeleteVault() {
|
||||
try {
|
||||
await this.#storage.deleteVault();
|
||||
this.#startup.startOver();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<div class="sam-text-header sam-mb-2">
|
||||
<span>Gooti Setup - Sync Preference</span>
|
||||
</div>
|
||||
|
||||
<span class="sam-text-muted sam-text-md sam-text-align-center2">
|
||||
Gooti 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 Google Chrome browser instances. You
|
||||
need to be signed in with your Google account.
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="sam-mt btn btn-primary"
|
||||
(click)="onClickSync(true)"
|
||||
>
|
||||
<span> Sync ON</span>
|
||||
</button>
|
||||
|
||||
<span class="sam-mt-2 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="sam-flex-grow"></div>
|
||||
|
||||
<span class="sam-text-muted sam-text-md sam-mb">
|
||||
Your preference can later be changed at any time.
|
||||
</span>
|
||||
@@ -0,0 +1,8 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
padding-left: var(--size);
|
||||
padding-right: var(--size);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { StartupService } from './startup.service';
|
||||
|
||||
describe('StartupService', () => {
|
||||
let service: StartupService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(StartupService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
88
projects/chrome/src/app/services/startup/startup.service.ts
Normal file
88
projects/chrome/src/app/services/startup/startup.service.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { LoggerService, StorageService, StorageServiceConfig } from '@common';
|
||||
import { ChromeSessionHandler } from '../../common/data/chrome-session-handler';
|
||||
import { ChromeSyncYesHandler } from '../../common/data/chrome-sync-yes-handler';
|
||||
import { ChromeSyncNoHandler } from '../../common/data/chrome-sync-no-handler';
|
||||
import { ChromeMetaHandler } from '../../common/data/chrome-meta-handler';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class StartupService {
|
||||
readonly #logger = inject(LoggerService);
|
||||
readonly #storage = inject(StorageService);
|
||||
readonly #router = inject(Router);
|
||||
|
||||
async startOver() {
|
||||
const storageConfig: StorageServiceConfig = {
|
||||
browserSessionHandler: new ChromeSessionHandler(),
|
||||
browserSyncYesHandler: new ChromeSyncYesHandler(),
|
||||
browserSyncNoHandler: new ChromeSyncNoHandler(),
|
||||
gootiMetaHandler: new ChromeMetaHandler(),
|
||||
};
|
||||
|
||||
this.#storage.initialize(storageConfig);
|
||||
|
||||
// Step 0:
|
||||
storageConfig.browserSyncNoHandler.setIgnoreProperties(
|
||||
storageConfig.gootiMetaHandler.metaProperties
|
||||
);
|
||||
|
||||
// Step 1: Load the gooti's user settings
|
||||
const gootiMetaData = await this.#storage.loadGootiMetaData();
|
||||
if (typeof gootiMetaData?.syncFlow === 'undefined') {
|
||||
// Very first run. The user has not set up Gooti yet.
|
||||
this.#router.navigateByUrl('/welcome');
|
||||
return;
|
||||
}
|
||||
this.#storage.enableBrowserSyncFlow(gootiMetaData.syncFlow);
|
||||
|
||||
// Load the browser session data.
|
||||
const browserSessionData = await this.#storage.loadBrowserSessionData();
|
||||
|
||||
if (!browserSessionData) {
|
||||
await this.#initializeFlow_A();
|
||||
} else {
|
||||
await this.#initializeFlow_B();
|
||||
}
|
||||
}
|
||||
|
||||
async #initializeFlow_A() {
|
||||
// Starting with NO browser session data available.
|
||||
//
|
||||
// This could be because the browser sync data was
|
||||
// never loaded before OR it was attempted, but
|
||||
// there is no browser sync data.
|
||||
|
||||
this.#logger.log('No browser session data available.');
|
||||
|
||||
// Check if there is NO browser sync data.
|
||||
const browserSyncData = await this.#storage.loadAndMigrateBrowserSyncData();
|
||||
if (browserSyncData) {
|
||||
// There is browser sync data. Route to the VAULT LOGIN to enable the session.
|
||||
this.#router.navigateByUrl('/vault-login');
|
||||
} else {
|
||||
// There is NO browser sync data. Route to the VAULT CREATION to enable the session.
|
||||
this.#router.navigateByUrl('/vault-create/home');
|
||||
}
|
||||
}
|
||||
|
||||
async #initializeFlow_B() {
|
||||
// Stating with browser session data available. The user has already unlocked the vault before.
|
||||
// Route to VAULT HOME.
|
||||
|
||||
this.#logger.log('Browser session data is available.');
|
||||
|
||||
// Also load the browser sync data. This is needed, if the user adds or deletes anything.
|
||||
await this.#storage.loadAndMigrateBrowserSyncData();
|
||||
|
||||
const selectedIdentityId =
|
||||
this.#storage.getBrowserSessionHandler().browserSessionData
|
||||
?.selectedIdentityId;
|
||||
|
||||
this.#router.navigateByUrl(
|
||||
`/home/${selectedIdentityId ? 'identity' : 'identities'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
283
projects/chrome/src/background-common.ts
Normal file
283
projects/chrome/src/background-common.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import {
|
||||
BrowserSessionData,
|
||||
BrowserSyncData,
|
||||
BrowserSyncFlow,
|
||||
CryptoHelper,
|
||||
GootiMetaData,
|
||||
Identity_DECRYPTED,
|
||||
Nip07Method,
|
||||
Nip07MethodPolicy,
|
||||
NostrHelper,
|
||||
Permission_DECRYPTED,
|
||||
Permission_ENCRYPTED,
|
||||
} from '@common';
|
||||
import { ChromeMetaHandler } from './app/common/data/chrome-meta-handler';
|
||||
import { Event, EventTemplate, finalizeEvent, nip04 } from 'nostr-tools';
|
||||
|
||||
export const debug = function (message: any) {
|
||||
const dateString = new Date().toISOString();
|
||||
console.log(`[Gooti - ${dateString}]: ${JSON.stringify(message)}`);
|
||||
};
|
||||
|
||||
export type PromptResponse =
|
||||
| 'reject'
|
||||
| 'reject-once'
|
||||
| 'approve'
|
||||
| 'approve-once';
|
||||
|
||||
export interface PromptResponseMessage {
|
||||
id: string;
|
||||
response: PromptResponse;
|
||||
}
|
||||
|
||||
export interface BackgroundRequestMessage {
|
||||
method: Nip07Method;
|
||||
params: any;
|
||||
host: string;
|
||||
}
|
||||
|
||||
export const getBrowserSessionData = async function (): Promise<
|
||||
BrowserSessionData | undefined
|
||||
> {
|
||||
const browserSessionData = await chrome.storage.session.get(null);
|
||||
if (Object.keys(browserSessionData).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return browserSessionData as BrowserSessionData;
|
||||
};
|
||||
|
||||
export const getBrowserSyncData = async function (): Promise<
|
||||
BrowserSyncData | undefined
|
||||
> {
|
||||
const gootiMetaHandler = new ChromeMetaHandler();
|
||||
const gootiMetaData =
|
||||
(await gootiMetaHandler.loadFullData()) as GootiMetaData;
|
||||
|
||||
let browserSyncData: BrowserSyncData | undefined;
|
||||
|
||||
if (gootiMetaData.syncFlow === BrowserSyncFlow.NO_SYNC) {
|
||||
browserSyncData = (await chrome.storage.local.get(null)) as BrowserSyncData;
|
||||
} else if (gootiMetaData.syncFlow === BrowserSyncFlow.BROWSER_SYNC) {
|
||||
browserSyncData = (await chrome.storage.sync.get(null)) as BrowserSyncData;
|
||||
}
|
||||
|
||||
return browserSyncData;
|
||||
};
|
||||
|
||||
export const savePermissionsToBrowserSyncStorage = async function (
|
||||
permissions: Permission_ENCRYPTED[]
|
||||
): Promise<void> {
|
||||
const gootiMetaHandler = new ChromeMetaHandler();
|
||||
const gootiMetaData =
|
||||
(await gootiMetaHandler.loadFullData()) as GootiMetaData;
|
||||
|
||||
if (gootiMetaData.syncFlow === BrowserSyncFlow.NO_SYNC) {
|
||||
await chrome.storage.local.set({ permissions });
|
||||
} else if (gootiMetaData.syncFlow === BrowserSyncFlow.BROWSER_SYNC) {
|
||||
await chrome.storage.sync.set({ permissions });
|
||||
}
|
||||
};
|
||||
|
||||
export const checkPermissions = function (
|
||||
browserSessionData: BrowserSessionData,
|
||||
identity: Identity_DECRYPTED,
|
||||
host: string,
|
||||
method: Nip07Method,
|
||||
params: any
|
||||
): boolean | undefined {
|
||||
const permissions = browserSessionData.permissions.filter(
|
||||
(x) =>
|
||||
x.identityId === identity.id && x.host === host && x.method === method
|
||||
);
|
||||
|
||||
if (permissions.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (method === 'getPublicKey') {
|
||||
// No evaluation of params required.
|
||||
return permissions.every((x) => x.methodPolicy === 'allow');
|
||||
}
|
||||
|
||||
if (method === 'getRelays') {
|
||||
// No evaluation of params required.
|
||||
return permissions.every((x) => x.methodPolicy === 'allow');
|
||||
}
|
||||
|
||||
if (method === 'signEvent') {
|
||||
// Evaluate params.
|
||||
const eventTemplate = params as EventTemplate;
|
||||
if (
|
||||
permissions.find(
|
||||
(x) => x.methodPolicy === 'allow' && typeof x.kind === 'undefined'
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
permissions.some(
|
||||
(x) => x.methodPolicy === 'allow' && x.kind === eventTemplate.kind
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
permissions.some(
|
||||
(x) => x.methodPolicy === 'deny' && x.kind === eventTemplate.kind
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (method === 'nip04.encrypt') {
|
||||
// No evaluation of params required.
|
||||
return permissions.every((x) => x.methodPolicy === 'allow');
|
||||
}
|
||||
|
||||
if (method === 'nip04.decrypt') {
|
||||
// No evaluation of params required.
|
||||
return permissions.every((x) => x.methodPolicy === 'allow');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const storePermission = async function (
|
||||
browserSessionData: BrowserSessionData,
|
||||
identity: Identity_DECRYPTED,
|
||||
host: string,
|
||||
method: Nip07Method,
|
||||
methodPolicy: Nip07MethodPolicy,
|
||||
kind?: number
|
||||
) {
|
||||
const browserSyncData = await getBrowserSyncData();
|
||||
if (!browserSyncData) {
|
||||
throw new Error(`Could not retrieve sync data`);
|
||||
}
|
||||
|
||||
const permission: Permission_DECRYPTED = {
|
||||
id: crypto.randomUUID(),
|
||||
identityId: identity.id,
|
||||
host,
|
||||
method,
|
||||
methodPolicy,
|
||||
kind,
|
||||
};
|
||||
|
||||
// Store session data
|
||||
await chrome.storage.session.set({
|
||||
permissions: [...browserSessionData.permissions, permission],
|
||||
});
|
||||
|
||||
// Encrypt permission to store in sync storage (depending on sync flow).
|
||||
const encryptedPermission = await encryptPermission(
|
||||
permission,
|
||||
browserSessionData.iv,
|
||||
browserSessionData.vaultPassword as string
|
||||
);
|
||||
|
||||
await savePermissionsToBrowserSyncStorage([
|
||||
...browserSyncData.permissions,
|
||||
encryptedPermission,
|
||||
]);
|
||||
};
|
||||
|
||||
export const getPosition = async function (width: number, height: number) {
|
||||
let left = 0;
|
||||
let top = 0;
|
||||
|
||||
try {
|
||||
const lastFocused = await chrome.windows.getLastFocused();
|
||||
|
||||
if (
|
||||
lastFocused &&
|
||||
lastFocused.top !== undefined &&
|
||||
lastFocused.left !== undefined &&
|
||||
lastFocused.width !== undefined &&
|
||||
lastFocused.height !== undefined
|
||||
) {
|
||||
// Position window in the center of the lastFocused window
|
||||
top = Math.round(lastFocused.top + (lastFocused.height - height) / 2);
|
||||
left = Math.round(lastFocused.left + (lastFocused.width - width) / 2);
|
||||
} else {
|
||||
console.error('Last focused window properties are undefined.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting window position:', error);
|
||||
}
|
||||
|
||||
return {
|
||||
top,
|
||||
left,
|
||||
};
|
||||
};
|
||||
|
||||
export const signEvent = function (
|
||||
eventTemplate: EventTemplate,
|
||||
privkey: string
|
||||
): Event {
|
||||
return finalizeEvent(eventTemplate, NostrHelper.hex2bytes(privkey));
|
||||
};
|
||||
|
||||
export const nip04Encrypt = async function (
|
||||
privkey: string,
|
||||
peerPubkey: string,
|
||||
plaintext: string
|
||||
): Promise<string> {
|
||||
return await nip04.encrypt(
|
||||
NostrHelper.hex2bytes(privkey),
|
||||
peerPubkey,
|
||||
plaintext
|
||||
);
|
||||
};
|
||||
|
||||
export const nip04Decrypt = async function (
|
||||
privkey: string,
|
||||
peerPubkey: string,
|
||||
ciphertext: string
|
||||
): Promise<string> {
|
||||
return await nip04.decrypt(
|
||||
NostrHelper.hex2bytes(privkey),
|
||||
peerPubkey,
|
||||
ciphertext
|
||||
);
|
||||
};
|
||||
|
||||
const encryptPermission = async function (
|
||||
permission: Permission_DECRYPTED,
|
||||
iv: string,
|
||||
password: string
|
||||
): Promise<Permission_ENCRYPTED> {
|
||||
const encryptedPermission: Permission_ENCRYPTED = {
|
||||
id: await encrypt(permission.id, iv, password),
|
||||
identityId: await encrypt(permission.identityId, iv, password),
|
||||
host: await encrypt(permission.host, iv, password),
|
||||
method: await encrypt(permission.method, iv, password),
|
||||
methodPolicy: await encrypt(permission.methodPolicy, iv, password),
|
||||
};
|
||||
|
||||
if (typeof permission.kind !== 'undefined') {
|
||||
encryptedPermission.kind = await encrypt(
|
||||
permission.kind.toString(),
|
||||
iv,
|
||||
password
|
||||
);
|
||||
}
|
||||
|
||||
return encryptedPermission;
|
||||
};
|
||||
|
||||
const encrypt = async function (
|
||||
value: string,
|
||||
iv: string,
|
||||
password: string
|
||||
): Promise<string> {
|
||||
return await CryptoHelper.encrypt(value, iv, password);
|
||||
};
|
||||
148
projects/chrome/src/background.ts
Normal file
148
projects/chrome/src/background.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { NostrHelper } from '@common';
|
||||
import {
|
||||
BackgroundRequestMessage,
|
||||
checkPermissions,
|
||||
debug,
|
||||
getBrowserSessionData,
|
||||
getPosition,
|
||||
nip04Decrypt,
|
||||
nip04Encrypt,
|
||||
PromptResponse,
|
||||
PromptResponseMessage,
|
||||
signEvent,
|
||||
storePermission,
|
||||
} from './background-common';
|
||||
import browser from 'webextension-polyfill';
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
type Relays = Record<string, { read: boolean; write: boolean }>;
|
||||
|
||||
const openPrompts = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (response: PromptResponse) => void;
|
||||
reject: (reason?: any) => void;
|
||||
}
|
||||
>();
|
||||
|
||||
browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
||||
debug('Message received');
|
||||
const request = message as BackgroundRequestMessage | PromptResponseMessage;
|
||||
debug(request);
|
||||
|
||||
if ((request as PromptResponseMessage)?.id) {
|
||||
// Handle prompt response
|
||||
const promptResponse = request as PromptResponseMessage;
|
||||
const openPrompt = openPrompts.get(promptResponse.id);
|
||||
if (!openPrompt) {
|
||||
throw new Error(
|
||||
'Prompt response could not be matched to any previous request.'
|
||||
);
|
||||
}
|
||||
|
||||
openPrompt.resolve(promptResponse.response);
|
||||
openPrompts.delete(promptResponse.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const browserSessionData = await getBrowserSessionData();
|
||||
|
||||
if (!browserSessionData) {
|
||||
throw new Error('Gooti vault not unlocked by the user.');
|
||||
}
|
||||
|
||||
const currentIdentity = browserSessionData.identities.find(
|
||||
(x) => x.id === browserSessionData.selectedIdentityId
|
||||
);
|
||||
|
||||
if (!currentIdentity) {
|
||||
throw new Error('No Nostr identity available at endpoint.');
|
||||
}
|
||||
|
||||
const req = request as BackgroundRequestMessage;
|
||||
const permissionState = checkPermissions(
|
||||
browserSessionData,
|
||||
currentIdentity,
|
||||
req.host,
|
||||
req.method,
|
||||
req.params
|
||||
);
|
||||
|
||||
if (permissionState === false) {
|
||||
throw new Error('Permission denied');
|
||||
}
|
||||
|
||||
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 = {};
|
||||
switch (req.method) {
|
||||
case 'getPublicKey':
|
||||
return NostrHelper.pubkeyFromPrivkey(currentIdentity.privkey);
|
||||
|
||||
case 'signEvent':
|
||||
return signEvent(req.params, currentIdentity.privkey);
|
||||
|
||||
case 'getRelays':
|
||||
browserSessionData.relays.forEach((x) => {
|
||||
relays[x.url] = { read: x.read, write: x.write };
|
||||
});
|
||||
return relays;
|
||||
|
||||
case 'nip04.encrypt':
|
||||
return await nip04Encrypt(
|
||||
currentIdentity.privkey,
|
||||
req.params.peerPubkey,
|
||||
req.params.plaintext
|
||||
);
|
||||
|
||||
case 'nip04.decrypt':
|
||||
return await nip04Decrypt(
|
||||
currentIdentity.privkey,
|
||||
req.params.peerPubkey,
|
||||
req.params.ciphertext
|
||||
);
|
||||
|
||||
default:
|
||||
throw new Error(`Not supported request method '${req.method}'.`);
|
||||
}
|
||||
});
|
||||
42
projects/chrome/src/gooti-content-script.ts
Normal file
42
projects/chrome/src/gooti-content-script.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import browser from 'webextension-polyfill';
|
||||
import { BackgroundRequestMessage } from './background-common';
|
||||
|
||||
// Inject the script that will provide window.nostr
|
||||
// The script needs to run before any other scripts from the real
|
||||
// page run (and maybe check for window.nostr).
|
||||
const script = document.createElement('script');
|
||||
script.setAttribute('async', 'false');
|
||||
script.setAttribute('type', 'text/javascript');
|
||||
script.setAttribute('src', browser.runtime.getURL('gooti-extension.js'));
|
||||
(document.head || document.documentElement).appendChild(script);
|
||||
|
||||
// listen for messages from that script
|
||||
window.addEventListener('message', async (message) => {
|
||||
// We will also receive our own messages, that we sent.
|
||||
// We have to ignore them (they will not have a params field).
|
||||
|
||||
if (message.source !== window) return;
|
||||
if (!message.data) return;
|
||||
if (!message.data.params) return;
|
||||
if (message.data.ext !== 'gooti') return;
|
||||
|
||||
// pass on to background
|
||||
let response;
|
||||
try {
|
||||
const request: BackgroundRequestMessage = {
|
||||
method: message.data.method,
|
||||
params: message.data.params,
|
||||
host: location.host,
|
||||
};
|
||||
|
||||
response = await browser.runtime.sendMessage(request);
|
||||
} catch (error) {
|
||||
response = { error };
|
||||
}
|
||||
|
||||
// return response
|
||||
window.postMessage(
|
||||
{ id: message.data.id, ext: 'gooti', response },
|
||||
message.origin
|
||||
);
|
||||
});
|
||||
128
projects/chrome/src/gooti-extension.ts
Normal file
128
projects/chrome/src/gooti-extension.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Event, EventTemplate } from 'nostr-tools';
|
||||
import { Nip07Method } from '@common';
|
||||
|
||||
type Relays = Record<string, { read: boolean; write: boolean }>;
|
||||
|
||||
class Messenger {
|
||||
#requests = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason: any) => void;
|
||||
}
|
||||
>();
|
||||
|
||||
constructor() {
|
||||
window.addEventListener('message', this.#handleCallResponse.bind(this));
|
||||
}
|
||||
|
||||
async request(method: Nip07Method, params: any): Promise<any> {
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.#requests.set(id, { resolve, reject });
|
||||
window.postMessage(
|
||||
{
|
||||
id,
|
||||
ext: 'gooti',
|
||||
method,
|
||||
params,
|
||||
},
|
||||
'*'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#handleCallResponse(message: MessageEvent) {
|
||||
// We also will receive our own messages, that we sent.
|
||||
// We have to ignore them (they will not have a response field).
|
||||
if (
|
||||
!message.data ||
|
||||
message.data.response === null ||
|
||||
message.data.response === undefined ||
|
||||
message.data.ext !== 'gooti' ||
|
||||
!this.#requests.has(message.data.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.data.response.error) {
|
||||
this.#requests.get(message.data.id)?.reject(message.data.response.error);
|
||||
} else {
|
||||
this.#requests.get(message.data.id)?.resolve(message.data.response);
|
||||
}
|
||||
|
||||
this.#requests.delete(message.data.id);
|
||||
}
|
||||
}
|
||||
|
||||
const nostr = {
|
||||
messenger: new Messenger(),
|
||||
|
||||
async getPublicKey(): Promise<string> {
|
||||
debug('getPublicKey received');
|
||||
const pubkey = await this.messenger.request('getPublicKey', {});
|
||||
debug(`getPublicKey response:`);
|
||||
debug(pubkey);
|
||||
return pubkey;
|
||||
},
|
||||
|
||||
async signEvent(event: EventTemplate): Promise<Event> {
|
||||
debug('signEvent received');
|
||||
const signedEvent = await this.messenger.request('signEvent', event);
|
||||
debug('signEvent response:');
|
||||
debug(signedEvent);
|
||||
return signedEvent;
|
||||
},
|
||||
|
||||
async getRelays(): Promise<Relays> {
|
||||
debug('getRelays received');
|
||||
const relays = (await this.messenger.request('getRelays', {})) as Relays;
|
||||
debug('getRelays response:');
|
||||
debug(relays);
|
||||
return relays;
|
||||
},
|
||||
|
||||
nip04: {
|
||||
that: this,
|
||||
|
||||
async encrypt(peerPubkey: string, plaintext: string): Promise<string> {
|
||||
debug('nip04.encrypt received');
|
||||
const ciphertext = (await nostr.messenger.request('nip04.encrypt', {
|
||||
peerPubkey,
|
||||
plaintext,
|
||||
})) as string;
|
||||
debug('nip04.encrypt response:');
|
||||
debug(ciphertext);
|
||||
return ciphertext;
|
||||
},
|
||||
|
||||
async decrypt(peerPubkey: string, ciphertext: string): Promise<string> {
|
||||
debug('nip04.decrypt received');
|
||||
const plaintext = (await nostr.messenger.request('nip04.decrypt', {
|
||||
peerPubkey,
|
||||
ciphertext,
|
||||
})) as string;
|
||||
debug('nip04.decrypt response:');
|
||||
debug(plaintext);
|
||||
return plaintext;
|
||||
},
|
||||
},
|
||||
|
||||
// nip44: {
|
||||
// async encrypt(peer, plaintext) {
|
||||
// return window.nostr._call('nip44.encrypt', { peer, plaintext });
|
||||
// },
|
||||
|
||||
// async decrypt(peer, ciphertext) {
|
||||
// return window.nostr._call('nip44.decrypt', { peer, ciphertext });
|
||||
// },
|
||||
// },
|
||||
};
|
||||
|
||||
window.nostr = nostr as any;
|
||||
|
||||
const debug = function (value: any) {
|
||||
console.log(JSON.stringify(value));
|
||||
};
|
||||
13
projects/chrome/src/index.html
Normal file
13
projects/chrome/src/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Gooti</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!-- <link rel="icon" type="image/x-icon" href="favicon.ico"> -->
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
14
projects/chrome/src/main.ts
Normal file
14
projects/chrome/src/main.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { AppComponent } from './app/app.component';
|
||||
import './app/common/extensions/array';
|
||||
|
||||
bootstrapApplication(AppComponent, appConfig).catch((err) =>
|
||||
console.error(err)
|
||||
);
|
||||
|
||||
// declare global {
|
||||
// interface Window {
|
||||
// nostr: any;
|
||||
// }
|
||||
// }
|
||||
167
projects/chrome/src/prompt.ts
Normal file
167
projects/chrome/src/prompt.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import browser from 'webextension-polyfill';
|
||||
import { Buffer } from 'buffer';
|
||||
import { Nip07Method } from '@common';
|
||||
import { PromptResponse, PromptResponseMessage } from './background-common';
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const id = params.get('id') as string;
|
||||
const method = params.get('method') as Nip07Method;
|
||||
const host = params.get('host') as string;
|
||||
const nick = params.get('nick') as string;
|
||||
const event = Buffer.from(params.get('event') as string, 'base64').toString();
|
||||
|
||||
let title = '';
|
||||
switch (method) {
|
||||
case 'getPublicKey':
|
||||
title = 'Get Public Key';
|
||||
break;
|
||||
|
||||
case 'signEvent':
|
||||
title = 'Sign Event';
|
||||
break;
|
||||
|
||||
case 'nip04.encrypt':
|
||||
title = 'Encrypt';
|
||||
break;
|
||||
|
||||
case 'nip04.decrypt':
|
||||
title = 'Decrypt';
|
||||
break;
|
||||
|
||||
case 'getRelays':
|
||||
title = 'Get Relays';
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const titleSpanElement = document.getElementById('titleSpan');
|
||||
if (titleSpanElement) {
|
||||
titleSpanElement.innerText = title;
|
||||
}
|
||||
|
||||
Array.from(document.getElementsByClassName('nick-INSERT')).forEach(
|
||||
(element) => {
|
||||
(element as HTMLElement).innerText = nick;
|
||||
}
|
||||
);
|
||||
|
||||
Array.from(document.getElementsByClassName('host-INSERT')).forEach(
|
||||
(element) => {
|
||||
(element as HTMLElement).innerText = host;
|
||||
}
|
||||
);
|
||||
|
||||
const kindSpanElement = document.getElementById('kindSpan');
|
||||
if (kindSpanElement) {
|
||||
kindSpanElement.innerText = JSON.parse(event).kind;
|
||||
}
|
||||
|
||||
const cardGetPublicKeyElement = document.getElementById('cardGetPublicKey');
|
||||
if (cardGetPublicKeyElement) {
|
||||
if (method === 'getPublicKey') {
|
||||
// Do nothing.
|
||||
} else {
|
||||
cardGetPublicKeyElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const cardGetRelaysElement = document.getElementById('cardGetRelays');
|
||||
if (cardGetRelaysElement) {
|
||||
if (method === 'getRelays') {
|
||||
// Do nothing.
|
||||
} else {
|
||||
cardGetRelaysElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const cardSignEventElement = document.getElementById('cardSignEvent');
|
||||
const card2SignEventElement = document.getElementById('card2SignEvent');
|
||||
if (cardSignEventElement && card2SignEventElement) {
|
||||
if (method === 'signEvent') {
|
||||
const card2SignEvent_jsonElement = document.getElementById(
|
||||
'card2SignEvent_json'
|
||||
);
|
||||
if (card2SignEvent_jsonElement) {
|
||||
card2SignEvent_jsonElement.innerText = event;
|
||||
}
|
||||
} else {
|
||||
cardSignEventElement.style.display = 'none';
|
||||
card2SignEventElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const cardNip04EncryptElement = document.getElementById('cardNip04Encrypt');
|
||||
const card2Nip04EncryptElement = document.getElementById('card2Nip04Encrypt');
|
||||
if (cardNip04EncryptElement && card2Nip04EncryptElement) {
|
||||
if (method === 'nip04.encrypt') {
|
||||
const card2Nip04Encrypt_textElement = document.getElementById(
|
||||
'card2Nip04Encrypt_text'
|
||||
);
|
||||
if (card2Nip04Encrypt_textElement) {
|
||||
const eventObject: { peerPubkey: string; plaintext: string } =
|
||||
JSON.parse(event);
|
||||
card2Nip04Encrypt_textElement.innerText = eventObject.plaintext;
|
||||
}
|
||||
} else {
|
||||
cardNip04EncryptElement.style.display = 'none';
|
||||
card2Nip04EncryptElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const cardNip04DecryptElement = document.getElementById('cardNip04Decrypt');
|
||||
const card2Nip04DecryptElement = document.getElementById('card2Nip04Decrypt');
|
||||
if (cardNip04DecryptElement && card2Nip04DecryptElement) {
|
||||
if (method === 'nip04.decrypt') {
|
||||
const card2Nip04Decrypt_textElement = document.getElementById(
|
||||
'card2Nip04Decrypt_text'
|
||||
);
|
||||
if (card2Nip04Decrypt_textElement) {
|
||||
const eventObject: { peerPubkey: string; ciphertext: string } =
|
||||
JSON.parse(event);
|
||||
card2Nip04Decrypt_textElement.innerText = eventObject.ciphertext;
|
||||
}
|
||||
} else {
|
||||
cardNip04DecryptElement.style.display = 'none';
|
||||
card2Nip04DecryptElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Functions
|
||||
//
|
||||
|
||||
function deliver(response: PromptResponse) {
|
||||
const message: PromptResponseMessage = {
|
||||
id,
|
||||
response,
|
||||
};
|
||||
|
||||
browser.runtime.sendMessage(message);
|
||||
window.close();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const rejectJustOnceButton = document.getElementById('rejectJustOnceButton');
|
||||
rejectJustOnceButton?.addEventListener('click', () => {
|
||||
deliver('reject-once');
|
||||
});
|
||||
|
||||
const rejectButton = document.getElementById('rejectButton');
|
||||
rejectButton?.addEventListener('click', () => {
|
||||
deliver('reject');
|
||||
});
|
||||
|
||||
const approveJustOnceButton = document.getElementById(
|
||||
'approveJustOnceButton'
|
||||
);
|
||||
approveJustOnceButton?.addEventListener('click', () => {
|
||||
deliver('approve-once');
|
||||
});
|
||||
|
||||
const approveButton = document.getElementById('approveButton');
|
||||
approveButton?.addEventListener('click', () => {
|
||||
deliver('approve');
|
||||
});
|
||||
});
|
||||
20
projects/chrome/src/styles.scss
Normal file
20
projects/chrome/src/styles.scss
Normal file
@@ -0,0 +1,20 @@
|
||||
@use "sass:meta";
|
||||
|
||||
@include meta.load-css("../../../node_modules/bootstrap/scss/bootstrap");
|
||||
@include meta.load-css(
|
||||
"../../../node_modules/bootstrap-icons/font/bootstrap-icons.min.css"
|
||||
);
|
||||
|
||||
// Load the common styles
|
||||
@include meta.load-css("../../common/src/lib/styles/styles.scss");
|
||||
|
||||
body {
|
||||
height: 600px;
|
||||
width: 375px;
|
||||
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
background: var(--background);
|
||||
|
||||
margin: 0;
|
||||
}
|
||||
Reference in New Issue
Block a user