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.'} +

+
+ )}