feat(ext/sw): org_add_config message + handler (device-local org config write)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3b8DA2mXmLWkd4zDbHXHg
This commit is contained in:
adlee-was-taken
2026-06-27 12:23:57 -04:00
parent e370b06f85
commit 11e3aa41d1
5 changed files with 104 additions and 4 deletions

View File

@@ -0,0 +1,79 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { handleOrgAddConfig } from '../router/org-handlers';
import type { OrgConfig } from '../../shared/types';
const BASE_CONFIG: OrgConfig = {
orgId: 'org-1',
displayName: 'Acme Corp',
hostType: 'gitea',
hostUrl: 'https://git.acme.example',
repoPath: 'acme/vault',
apiToken: 'token-abc',
memberId: 'member-1',
};
function mockStorage(initial: OrgConfig[] = []) {
const store: Record<string, unknown> = initial.length
? { orgConfigs: initial }
: {};
const setMock = vi.fn().mockResolvedValue(undefined);
const getMock = vi.fn().mockResolvedValue({ ...store });
(global as { chrome: unknown }).chrome = {
storage: {
local: {
get: getMock,
set: setMock,
},
},
} as never;
return { getMock, setMock };
}
describe('handleOrgAddConfig', () => {
beforeEach(() => {
mockStorage([]);
});
it('appends the new config and returns { ok: true }', async () => {
const { setMock } = mockStorage([]);
const resp = await handleOrgAddConfig({ config: BASE_CONFIG });
expect(resp).toEqual({ ok: true });
expect(setMock).toHaveBeenCalledOnce();
const saved = (setMock.mock.calls[0][0] as { orgConfigs: OrgConfig[] }).orgConfigs;
expect(saved).toHaveLength(1);
expect(saved[0].orgId).toBe('org-1');
});
it('preserves existing configs when appending', async () => {
const existing: OrgConfig = { ...BASE_CONFIG, orgId: 'org-0', displayName: 'Pre-existing' };
const newCfg: OrgConfig = { ...BASE_CONFIG, orgId: 'org-2', displayName: 'New Org' };
const { setMock } = mockStorage([existing]);
const resp = await handleOrgAddConfig({ config: newCfg });
expect(resp).toEqual({ ok: true });
const saved = (setMock.mock.calls[0][0] as { orgConfigs: OrgConfig[] }).orgConfigs;
expect(saved).toHaveLength(2);
expect(saved.map((c: OrgConfig) => c.orgId)).toEqual(['org-0', 'org-2']);
});
it('returns { ok: false, error: "org_already_configured" } for duplicate orgId', async () => {
mockStorage([BASE_CONFIG]);
const resp = await handleOrgAddConfig({ config: BASE_CONFIG });
expect(resp).toEqual({ ok: false, error: 'org_already_configured' });
});
it('does NOT call saveOrgConfigs when rejecting a duplicate', async () => {
const { setMock } = mockStorage([BASE_CONFIG]);
await handleOrgAddConfig({ config: BASE_CONFIG });
expect(setMock).not.toHaveBeenCalled();
});
it('rejects duplicate even when display name differs', async () => {
mockStorage([BASE_CONFIG]);
const renamed = { ...BASE_CONFIG, displayName: 'Acme Corp Renamed' };
const resp = await handleOrgAddConfig({ config: renamed });
expect(resp).toEqual({ ok: false, error: 'org_already_configured' });
});
});

View File

@@ -11,9 +11,9 @@
import { openOrg, listOrgItems, getOrgItem, listOrgCollections, isNetworkError } from '../org-vault';
import type { OrgHandleState } from '../org-vault';
import { loadOrgConfigs } from '../org-config';
import { loadOrgConfigs, saveOrgConfigs } from '../org-config';
import * as session from '../session';
import type { OrgConfigSummary } from '../../shared/types';
import type { OrgConfig, OrgConfigSummary } from '../../shared/types';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type WasmModule = any;
@@ -88,3 +88,15 @@ export async function handleOrgListConfigs(): Promise<{ ok: true; data: OrgConfi
data: cfgs.map(c => ({ orgId: c.orgId, displayName: c.displayName })),
};
}
/** Handle org_add_config: append a new device-local org config (reject duplicate orgId). */
export async function handleOrgAddConfig(
{ config }: { config: OrgConfig },
): Promise<{ ok: true } | { ok: false; error: string }> {
const cfgs = await loadOrgConfigs();
if (cfgs.some(c => c.orgId === config.orgId)) {
return { ok: false, error: 'org_already_configured' };
}
await saveOrgConfigs([...cfgs, config]);
return { ok: true };
}

View File

@@ -16,6 +16,7 @@ import * as sessionTimer from '../session-timer';
import { loadDeviceSettings, saveDeviceSettings, loadBlacklist, saveBlacklist } from '../storage';
import {
handleOrgListConfigs,
handleOrgAddConfig,
handleOrgSwitch,
handleOrgListItems,
handleOrgGetItem,
@@ -671,6 +672,9 @@ export async function handle(
case 'org_list_configs':
return handleOrgListConfigs();
case 'org_add_config':
return handleOrgAddConfig(msg);
case 'org_switch':
return handleOrgSwitch(msg, state.wasm);

View File

@@ -101,6 +101,10 @@ export const ERROR_COPY: Record<string, ErrorCopy> = {
cta: { label: 'Open setup', action: 'open_setup' },
},
// --- Org (enterprise) context errors (v0.9.0 Plan B) ---
org_already_configured: {
title: 'Already added',
body: 'That organization is already configured on this device.',
},
org_not_configured: {
title: 'Org not set up here',
body: 'This organization is not configured on this device yet. Add it from the vault switcher before browsing its items.',

View File

@@ -1,7 +1,7 @@
import type {
Item, ItemId, Manifest, ManifestEntry, VaultConfig, SetupState,
DeviceSettings, GeneratorRequest, VaultSettings, AttachmentRef, Device,
FieldHistoryView, OrgConfigSummary, OrgManifestEntry, Collection,
FieldHistoryView, OrgConfig, OrgConfigSummary, OrgManifestEntry, Collection,
} from './types';
// --- Session timeout config ---
@@ -70,6 +70,7 @@ export type PopupMessage =
referenceImageBytes: ArrayBuffer; deviceName: string }
| { type: 'get_vault_status' }
| { type: 'org_list_configs' }
| { type: 'org_add_config'; config: OrgConfig }
| { type: 'org_switch'; context: string }
| { type: 'org_list_items' }
| { type: 'org_get_item'; id: string }
@@ -187,7 +188,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
'preview_totp_from_secret',
'generate_recovery_qr', 'unwrap_recovery_qr',
'create_vault', 'attach_vault', 'get_vault_status',
'org_list_configs',
'org_list_configs', 'org_add_config',
'org_switch', 'org_list_items', 'org_get_item', 'org_list_collections',
] as PopupMessage['type'][]);