From 5e0278abd0cdbb735dc7efb0d7f1c3deb680c512 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 9 Aug 2026 08:50:04 +0000 Subject: [PATCH] feat(desktop): personal or organization principals, chosen not typed --- apps/desktop/__tests__/principal.test.ts | 41 +++++++- apps/desktop/src/main/ipc.ts | 2 +- .../renderer/src/screens/AccountsScreen.tsx | 98 ++++++++++++++++--- apps/desktop/src/shared/api.ts | 3 +- apps/desktop/src/shared/principal.ts | 25 ++++- packages/accounts/__tests__/accounts.test.ts | 13 ++- packages/accounts/src/client.ts | 75 ++++++++++---- packages/accounts/src/types.ts | 8 +- packages/cli/__tests__/account.test.ts | 6 +- packages/cli/src/commands/account.ts | 5 +- 10 files changed, 231 insertions(+), 45 deletions(-) diff --git a/apps/desktop/__tests__/principal.test.ts b/apps/desktop/__tests__/principal.test.ts index 2462f29..4f4795d 100644 --- a/apps/desktop/__tests__/principal.test.ts +++ b/apps/desktop/__tests__/principal.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import type { PrincipalRecord } from '../src/shared/api'; -import { principalReach } from '../src/shared/principal'; +import type { ApiKeyRecord, PrincipalRecord } from '../src/shared/api'; +import { knownOrgIds, principalReach } from '../src/shared/principal'; const principal = (overrides: Partial = {}): PrincipalRecord => ({ principalId: 'principal-1', @@ -15,6 +15,43 @@ const principal = (overrides: Partial = {}): PrincipalRecord => ...overrides, }); +const key = (overrides: Partial = {}): ApiKeyRecord => ({ + itemId: 'item-1', + accountItemId: 'account-1', + endpoint: 'http://auth.localhost:3000/graphql', + keyId: 'key-1', + name: 'ci', + expiresAt: null, + databaseId: null, + principalId: null, + orgId: null, + ...overrides, +}); + +describe('knownOrgIds', () => { + it('gathers the organizations already scoped, from principals and org keys alike', () => { + expect( + knownOrgIds( + [principal({ entityIds: ['org-b'] })], + [key({ orgId: 'org-a' }), key({ itemId: 'item-2' })] + ) + ).toEqual(['org-a', 'org-b']); + }); + + it('offers each organization once, however many things are scoped to it', () => { + expect( + knownOrgIds( + [principal({ entityIds: ['org-a'] }), principal({ entityIds: ['org-a'] })], + [key({ orgId: 'org-a' })] + ) + ).toEqual(['org-a']); + }); + + it('is empty for an account that has scoped nothing, so the id must be typed', () => { + expect(knownOrgIds([principal({ entityIds: [] })], [key()])).toEqual([]); + }); +}); + describe('principalReach', () => { it('says it inherits, rather than showing nothing, when no scope is overridden', () => { expect(principalReach(principal())).toBe('inherits you everywhere it is scoped'); diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 44b6154..5b03037 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -279,7 +279,7 @@ export const registerIpc = (service: VaultService): void => { assertString(accountItemId), { name: assertString(request?.name), - orgId: assertString(request?.orgId), + orgId: request?.orgId === undefined ? undefined : assertString(request.orgId), isReadOnly: Boolean(request?.isReadOnly), bypassStepUp: Boolean(request?.bypassStepUp), }, diff --git a/apps/desktop/src/renderer/src/screens/AccountsScreen.tsx b/apps/desktop/src/renderer/src/screens/AccountsScreen.tsx index ea47dec..19c34d8 100644 --- a/apps/desktop/src/renderer/src/screens/AccountsScreen.tsx +++ b/apps/desktop/src/renderer/src/screens/AccountsScreen.tsx @@ -17,6 +17,13 @@ import { } from '@constructive-io/ui/dialog'; import { Input } from '@constructive-io/ui/input'; import { Label } from '@constructive-io/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@constructive-io/ui/select'; import { Separator } from '@constructive-io/ui/separator'; import { Copy, @@ -40,7 +47,7 @@ import type { StepUpProof, TotpEntry, } from '../../../shared/api'; -import { principalReach } from '../../../shared/principal'; +import { knownOrgIds, principalReach } from '../../../shared/principal'; import { StepUpKind, stepUpKind, @@ -55,6 +62,14 @@ const message = (error: unknown): string => const expiry = (iso: string | null): string => iso ? `expires ${new Date(iso).toLocaleString()}` : 'no expiry'; +/** + * Personal reaches wherever its owner does; organization narrows it to one org. + * A third choice — "another organization" — only picks the id, not the scope. + */ +type PrincipalScope = 'personal' | 'organization'; + +const OTHER_ORG = 'other'; + /** A request the server refused until a factor is re-proved, kept to replay. */ interface HeldRequest { kind: StepUpKind; @@ -83,6 +98,8 @@ export const AccountsScreen = () => { const [principals, setPrincipals] = useState>({}); const [principalFor, setPrincipalFor] = useState(null); const [principalName, setPrincipalName] = useState(''); + const [principalScope, setPrincipalScope] = useState('personal'); + const [principalOrgChoice, setPrincipalOrgChoice] = useState(''); const [principalOrg, setPrincipalOrg] = useState(''); const [principalReadOnly, setPrincipalReadOnly] = useState(true); const [principalBypass, setPrincipalBypass] = useState(false); @@ -199,18 +216,30 @@ export const AccountsScreen = () => { }); }; + const orgOptions = principalFor + ? knownOrgIds( + principals[principalFor.itemId] ?? [], + keys.filter((key) => key.accountItemId === principalFor.itemId) + ) + : []; + const chosenOrgId = + principalOrgChoice === OTHER_ORG ? principalOrg.trim() : principalOrgChoice; + const createPrincipal = async (): Promise => { const account = principalFor; if (!account) return; const request = { name: principalName.trim(), - orgId: principalOrg.trim(), + // a personal principal carries no org at all, rather than an empty one + ...(principalScope === 'organization' ? { orgId: chosenOrgId } : {}), isReadOnly: principalReadOnly, bypassStepUp: principalBypass, }; await run(async (proof) => { await dcrypt.accounts.createPrincipal(account.itemId, request, proof); setPrincipalName(''); + setPrincipalScope('personal'); + setPrincipalOrgChoice(''); setPrincipalOrg(''); setPrincipalFor(null); return `Created ${request.name} — mint a key as it to give it credentials`; @@ -650,18 +679,59 @@ export const AccountsScreen = () => { />
- - setPrincipalOrg(e.target.value)} - placeholder="org id" - className="font-mono" - /> + +

- The organization it is scoped to. + {principalScope === 'personal' + ? 'Owned by you, reaching wherever you do — the shape an unattended job of your own wants.' + : 'Narrowed to one organization you work in.'}

+ {principalScope === 'organization' && ( +
+ + + {principalOrgChoice === OTHER_ORG && ( + setPrincipalOrg(e.target.value)} + placeholder="org id" + className="font-mono" + /> + )} +

+ {orgOptions.length + ? 'Organizations this account has already scoped a principal or key to.' + : 'Nothing scoped yet from this account, so the id has to be typed once.'} +

+
+ )}