import { ComponentMetaspaceScope } from "../../component-types.mjs"; import thisCss from "./remove-device.css"; import thisHtml from "./remove-device.html"; import commonComponentStylesCss from "../../common-component-styles.css"; import { delay, extractErrorMsg } from "@ibgib/helper-gib/dist/helpers/utils-helper.mjs"; import { IbGibAddr } from "@ibgib/ts-gib/dist/types.mjs"; import { IbGib_V1 } from "@ibgib/ts-gib/dist/V1/types.mjs"; import { KeystoneService_V1 } from "@ibgib/core-gib/dist/keystone/keystone-service-v1.mjs"; import { KeystoneIbGib_V1 } from "@ibgib/core-gib/dist/keystone/keystone-types.mjs"; import { getContextModeService } from "../../../../context/context-mode-service.mjs"; import { IbGibDynamicComponentMetaBase, IbGibFormInstanceBase, } from "../../ibgib-dynamic-component-bases.mjs"; import { ElementsBase, IbGibDynamicComponentInstance, IbGibDynamicComponentInstanceInitOpts, } from "../../component-types.mjs"; import { getComponentCtorArg } from "../../../../app-bootstrap/init-orchestration.mjs"; export const REMOVE_DEVICE_COMPONENT_NAME = 'ibgib-remove-device'; /** * Metadata for the Remove Device / Revocation component. */ export class RemoveDeviceComponentMeta extends IbGibDynamicComponentMetaBase { protected lc: string = `[RemoveDeviceComponentMeta]`; routeRegExp?: RegExp = new RegExp(`^${REMOVE_DEVICE_COMPONENT_NAME}$`); componentName = REMOVE_DEVICE_COMPONENT_NAME; constructor() { super(getComponentCtorArg()); if (!customElements.get(this.componentName)) { customElements.define(this.componentName, RemoveDeviceComponentInstance); } } async createInstance({ path, ibGibAddr }: { path: string; ibGibAddr: IbGibAddr; }): Promise { const component = document.createElement(this.componentName) as RemoveDeviceComponentInstance; await component.initialize({ ibGibAddr, meta: this, html: thisHtml, css: [commonComponentStylesCss, thisCss], }); return component; } } interface RemoveDeviceElements extends ElementsBase { containerEl: HTMLDivElement; titleEl: HTMLHeadingElement; selectDelegateEl: HTMLSelectElement; inputPassword: HTMLInputElement; btnRemoveDeviceEl: HTMLButtonElement; statusArea: HTMLDivElement; statusText: HTMLParagraphElement; resultAreaEl: HTMLDivElement; } /** * Concrete implementation of the Remove Device / Revocation component. */ export class RemoveDeviceComponentInstance extends IbGibFormInstanceBase implements IbGibDynamicComponentInstance { /** Identity is device-scoped and mode-agnostic: always use the persistent metaspace. */ protected override metaspaceScope: ComponentMetaspaceScope = ComponentMetaspaceScope.persistent; protected lc: string = `[RemoveDeviceComponentInstance]`; private activePrimaryKeystone: KeystoneIbGib_V1 | undefined; constructor() { super(); } override async initialize(opts: IbGibDynamicComponentInstanceInitOpts): Promise { await super.initialize(opts); } override async created(): Promise { this.elements = {} as RemoveDeviceElements; this.elements.containerEl = this.shadowRoot!.getElementById('container') as HTMLDivElement; this.elements.titleEl = this.shadowRoot!.getElementById('title') as HTMLHeadingElement; this.elements.selectDelegateEl = this.shadowRoot!.getElementById('select-delegate') as HTMLSelectElement; this.elements.inputPassword = this.shadowRoot!.getElementById('input-password') as HTMLInputElement; this.elements.btnRemoveDeviceEl = this.shadowRoot!.getElementById('btn-remove-device') as HTMLButtonElement; this.elements.statusArea = this.shadowRoot!.getElementById('status-area') as HTMLDivElement; this.elements.statusText = this.shadowRoot!.getElementById('status-text') as HTMLParagraphElement; this.elements.resultAreaEl = this.shadowRoot!.getElementById('result-area') as HTMLDivElement; this.elements.contentEl = this.elements.containerEl; this.initHandlers(); await this.loadRegisteredDelegates(); await this.renderUI(); } override async disconnected(): Promise { // cleanup if needed } private initHandlers() { this.elements!.btnRemoveDeviceEl.addEventListener('click', () => this.handleRemoveDevice()); this.elements!.inputPassword.addEventListener('input', () => { this.elements!.inputPassword.classList.remove('invalid'); }); } /** * Loads registered delegate devices from the active primary identity keystone into the dropdown select. */ protected async loadRegisteredDelegates() { const lc = `${this.lc}[${this.loadRegisteredDelegates.name}]`; try { const metaspace = getContextModeService().getPersistentMetaspace(); const space = await metaspace.getLocalUserSpace({}); if (!space) { return; } const keystonesIndex = await metaspace.getSpecialIbGib({ type: "keystones", space, initialize: true, }); const registeredAddrs: string[] = keystonesIndex?.rel8ns?.keystone ?? []; // Find primary identity (n === 0 and has delegates map or is primary domain keystone) for (const addr of registeredAddrs) { const res = await metaspace.get({ addrs: [addr], space }); const keystone = res?.ibGibs?.[0] as KeystoneIbGib_V1 | undefined; if (keystone && keystone.data && !keystone.data.isDelegate) { this.activePrimaryKeystone = keystone; break; } } const selectEl = this.elements!.selectDelegateEl; selectEl.innerHTML = ''; const delegatesMap = this.activePrimaryKeystone?.data?.delegates ?? {}; const delegateTjpAddrs = Object.keys(delegatesMap); if (delegateTjpAddrs.length === 0) { const opt = document.createElement('option'); opt.value = ''; opt.text = 'No registered delegate devices found'; selectEl.appendChild(opt); this.elements!.btnRemoveDeviceEl.disabled = true; return; } const defaultOpt = document.createElement('option'); defaultOpt.value = ''; defaultOpt.text = '-- Select a device to unlink --'; selectEl.appendChild(defaultOpt); for (const tjpAddr of delegateTjpAddrs) { const info = delegatesMap[tjpAddr]; const opt = document.createElement('option'); opt.value = tjpAddr; opt.text = `${info.delegateAddr.split('^')[0]} (TJP: ${tjpAddr.substring(0, 20)}...)`; selectEl.appendChild(opt); } this.elements!.btnRemoveDeviceEl.disabled = false; } catch (error) { console.error(`${lc} ${extractErrorMsg(error)}`); } } /** * Executes the Phase 1 Remove-Device revocation orchestration flow. */ protected async handleRemoveDevice() { const lc = `${this.lc}[${this.handleRemoveDevice.name}]`; try { const humanEyeDelay = 500; const delegateTjpAddr = this.elements!.selectDelegateEl.value; if (!delegateTjpAddr) { this.setStatus("Please select a device delegate to unlink.", "error"); return; } const masterSecret = this.elements!.inputPassword.value.trim(); this.elements!.inputPassword.value = masterSecret; if (!masterSecret) { this.elements!.inputPassword.classList.add('invalid'); this.setStatus("Please enter your master password.", "error"); return; } if (!this.activePrimaryKeystone) { this.setStatus("No active primary identity keystone found.", "error"); return; } this.showBusy({ isBusy: true, title: 'Revoking Device...', msg: 'Evolving primary identity to revoke delegate device...', animationEmoji: '🔒', submitBtnSelector: '#btn-remove-device', loadingText: 'Unlinking...' }); const metaspace = getContextModeService().getPersistentMetaspace(); const space = await metaspace.getLocalUserSpace({}); if (!space) { throw new Error("No default space found in metaspace."); } const keystoneService = new KeystoneService_V1(); // Evolve primary identity keystone unregistering delegate const evolvedParent = await keystoneService.unregisterDelegate({ parentKeystone: this.activePrimaryKeystone, delegateTjpAddr, masterSecret, signingPoolId: 'manage', metaspace, space }); // Persist evolved parent to space await metaspace.put({ ibGibs: [evolvedParent], space }); await metaspace.registerNewIbGib({ ibGib: evolvedParent, space }); this.activePrimaryKeystone = evolvedParent; await delay(humanEyeDelay); this.showBusy({ isBusy: false }); this.elements!.resultAreaEl.classList.remove('collapsed'); this.setStatus("Device successfully unlinked and revoked!", "success"); // Refresh list await this.loadRegisteredDelegates(); } catch (error) { console.error(`${lc} ${extractErrorMsg(error)}`); this.setStatus(`Revocation failed: ${extractErrorMsg(error)}`, "error"); this.showBusy({ isBusy: false }); } } }