From e7431c223f6f7c97046be9b62c322a9e2f50dfd3 Mon Sep 17 00:00:00 2001
From: Dan Lynch
Date: Sun, 9 Aug 2026 08:25:19 +0000
Subject: [PATCH] =?UTF-8?q?feat(desktop):=20principals=20in=20the=20app=20?=
=?UTF-8?q?=E2=80=94=20create,=20see=20their=20scope,=20mint=20keys=20as?=
=?UTF-8?q?=20them?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
apps/desktop/__tests__/principal.test.ts | 64 ++++
apps/desktop/src/main/ipc.ts | 40 +++
apps/desktop/src/preload/index.ts | 7 +
.../renderer/src/screens/AccountsScreen.tsx | 288 +++++++++++++++++-
apps/desktop/src/shared/api.ts | 39 ++-
apps/desktop/src/shared/principal.ts | 28 ++
6 files changed, 463 insertions(+), 3 deletions(-)
create mode 100644 apps/desktop/__tests__/principal.test.ts
create mode 100644 apps/desktop/src/shared/principal.ts
diff --git a/apps/desktop/__tests__/principal.test.ts b/apps/desktop/__tests__/principal.test.ts
new file mode 100644
index 0000000..2462f29
--- /dev/null
+++ b/apps/desktop/__tests__/principal.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from 'vitest';
+
+import type { PrincipalRecord } from '../src/shared/api';
+import { principalReach } from '../src/shared/principal';
+
+const principal = (overrides: Partial = {}): PrincipalRecord => ({
+ principalId: 'principal-1',
+ name: 'ci-deploy',
+ ownerId: 'user-1',
+ isReadOnly: false,
+ bypassStepUp: false,
+ useAdminOwner: true,
+ entityIds: ['org-1'],
+ scopes: [],
+ ...overrides,
+});
+
+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');
+ });
+
+ it('names the restrictions it does carry', () => {
+ const text = principalReach(principal({ isReadOnly: true, bypassStepUp: true }));
+ expect(text).toContain('read-only');
+ expect(text).toContain('skips step-up');
+ });
+
+ it('shows a scope mask, and says when the scope is switched off', () => {
+ const text = principalReach(
+ principal({
+ scopes: [
+ {
+ membershipType: 2,
+ allowedMask: '0011',
+ isActive: false,
+ isReadOnly: true,
+ useAdminOwner: false,
+ },
+ ],
+ })
+ );
+ expect(text).toContain('scope 2');
+ expect(text).toContain('disabled');
+ expect(text).toContain('mask 0011');
+ });
+
+ it('calls an absent mask inherited, because it is not an empty one', () => {
+ const text = principalReach(
+ principal({
+ scopes: [
+ {
+ membershipType: 1,
+ allowedMask: null,
+ isActive: true,
+ isReadOnly: false,
+ useAdminOwner: true,
+ },
+ ],
+ })
+ );
+ expect(text).toContain('mask inherited');
+ });
+});
diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts
index 3951d0d..44b6154 100644
--- a/apps/desktop/src/main/ipc.ts
+++ b/apps/desktop/src/main/ipc.ts
@@ -10,6 +10,7 @@ import * as path from 'path';
import {
CHANNELS,
CreateKeyRequest,
+ CreatePrincipalRequest,
FieldPurpose,
ItemKind,
SignInRequest,
@@ -242,6 +243,11 @@ export const registerIpc = (service: VaultService): void => {
expiresIn: days === undefined ? undefined : { days: assertInt(days, 1, 3650) },
accessLevel:
request?.accessLevel === undefined ? undefined : assertString(request.accessLevel),
+ principalId:
+ request?.principalId === undefined ? undefined : assertString(request.principalId),
+ orgId: request?.orgId === undefined ? undefined : assertString(request.orgId),
+ databaseId:
+ request?.databaseId === undefined ? undefined : assertString(request.databaseId),
},
proof(stepUp)
);
@@ -256,6 +262,40 @@ export const registerIpc = (service: VaultService): void => {
await accounts().revokeApiKey(assertString(itemId), proof(stepUp));
service.scheduleSave();
});
+ handle(
+ CHANNELS.accountsAssignKeyDatabase,
+ async (itemId: string, databaseId: string) => {
+ await accounts().assignKeyToDatabase(assertString(itemId), assertString(databaseId));
+ service.scheduleSave();
+ }
+ );
+ handle(CHANNELS.accountsPrincipals, (accountItemId: string) =>
+ accounts().listPrincipals(assertString(accountItemId))
+ );
+ handle(
+ CHANNELS.accountsCreatePrincipal,
+ (accountItemId: string, request: CreatePrincipalRequest, stepUp?: StepUpProof) =>
+ accounts().createPrincipal(
+ assertString(accountItemId),
+ {
+ name: assertString(request?.name),
+ orgId: assertString(request?.orgId),
+ isReadOnly: Boolean(request?.isReadOnly),
+ bypassStepUp: Boolean(request?.bypassStepUp),
+ },
+ proof(stepUp)
+ )
+ );
+ handle(
+ CHANNELS.accountsDeletePrincipal,
+ async (accountItemId: string, principalId: string, stepUp?: StepUpProof) => {
+ await accounts().deletePrincipal(
+ assertString(accountItemId),
+ assertString(principalId),
+ proof(stepUp)
+ );
+ }
+ );
handle(CHANNELS.accountsLinkTotp, async (accountItemId: string, totpItemId: string) => {
await accounts().linkTotp(assertString(accountItemId), assertString(totpItemId));
service.scheduleSave();
diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts
index cbf952c..ce1ec12 100644
--- a/apps/desktop/src/preload/index.ts
+++ b/apps/desktop/src/preload/index.ts
@@ -63,6 +63,13 @@ const api: DcryptApi & {
invoke(CHANNELS.accountsCreateKey, accountItemId, request, stepUp),
revealKey: (itemId) => invoke(CHANNELS.accountsRevealKey, itemId),
revokeKey: (itemId, stepUp) => invoke(CHANNELS.accountsRevokeKey, itemId, stepUp),
+ assignKeyToDatabase: (itemId, databaseId) =>
+ invoke(CHANNELS.accountsAssignKeyDatabase, itemId, databaseId),
+ principals: (accountItemId) => invoke(CHANNELS.accountsPrincipals, accountItemId),
+ createPrincipal: (accountItemId, request, stepUp) =>
+ invoke(CHANNELS.accountsCreatePrincipal, accountItemId, request, stepUp),
+ deletePrincipal: (accountItemId, principalId, stepUp) =>
+ invoke(CHANNELS.accountsDeletePrincipal, accountItemId, principalId, stepUp),
linkTotp: (accountItemId, totpItemId) =>
invoke(CHANNELS.accountsLinkTotp, accountItemId, totpItemId),
unlinkTotp: (accountItemId) => invoke(CHANNELS.accountsUnlinkTotp, accountItemId),
diff --git a/apps/desktop/src/renderer/src/screens/AccountsScreen.tsx b/apps/desktop/src/renderer/src/screens/AccountsScreen.tsx
index 43f40ae..ea47dec 100644
--- a/apps/desktop/src/renderer/src/screens/AccountsScreen.tsx
+++ b/apps/desktop/src/renderer/src/screens/AccountsScreen.tsx
@@ -20,10 +20,12 @@ import { Label } from '@constructive-io/ui/label';
import { Separator } from '@constructive-io/ui/separator';
import {
Copy,
+ Database,
KeyRound,
LogIn,
LogOut,
Plus,
+ ShieldUser,
Timer,
Trash2,
UserPlus,
@@ -34,9 +36,11 @@ import { toast } from 'sonner';
import type {
AccountRecord,
ApiKeyRecord,
+ PrincipalRecord,
StepUpProof,
TotpEntry,
} from '../../../shared/api';
+import { principalReach } from '../../../shared/principal';
import {
StepUpKind,
stepUpKind,
@@ -70,6 +74,18 @@ export const AccountsScreen = () => {
const [keyFor, setKeyFor] = useState(null);
const [keyName, setKeyName] = useState('');
const [keyDays, setKeyDays] = useState('');
+ const [keyPrincipal, setKeyPrincipal] = useState(null);
+ const [keyDatabase, setKeyDatabase] = useState('');
+
+ const [tagFor, setTagFor] = useState(null);
+ const [tagValue, setTagValue] = useState('');
+
+ const [principals, setPrincipals] = useState>({});
+ const [principalFor, setPrincipalFor] = useState(null);
+ const [principalName, setPrincipalName] = useState('');
+ const [principalOrg, setPrincipalOrg] = useState('');
+ const [principalReadOnly, setPrincipalReadOnly] = useState(true);
+ const [principalBypass, setPrincipalBypass] = useState(false);
const [held, setHeld] = useState(null);
const [proofValue, setProofValue] = useState('');
@@ -87,6 +103,19 @@ export const AccountsScreen = () => {
setAccounts(nextAccounts);
setKeys(nextKeys);
setCodes(nextCodes);
+
+ // principals live on the server; a signed-out account simply has none to show
+ const signedIn = nextAccounts.filter((account) => account.signedIn);
+ const fetched = await Promise.all(
+ signedIn.map(async (account) => {
+ try {
+ return [account.itemId, await dcrypt.accounts.principals(account.itemId)] as const;
+ } catch {
+ return [account.itemId, []] as const;
+ }
+ })
+ );
+ setPrincipals(Object.fromEntries(fetched));
} catch {
// vault locked mid-refresh
}
@@ -150,13 +179,41 @@ export const AccountsScreen = () => {
toast.error('Expiry must be a whole number of days');
return;
}
- const request = { name: keyName.trim(), expiresDays: days };
+ const request = {
+ name: keyName.trim(),
+ expiresDays: days,
+ principalId: keyPrincipal?.principalId,
+ orgId: keyPrincipal?.entityIds[0],
+ databaseId: keyDatabase.trim() || undefined,
+ };
await run(async (proof) => {
const key = await dcrypt.accounts.createKey(account.itemId, request, proof);
setKeyName('');
setKeyDays('');
+ setKeyPrincipal(null);
+ setKeyDatabase('');
setKeyFor(null);
- return `Created "${key.name}" — the secret is in your vault`;
+ return keyPrincipal
+ ? `Created "${key.name}" as ${keyPrincipal.name} — the secret is in your vault`
+ : `Created "${key.name}" — the secret is in your vault`;
+ });
+ };
+
+ const createPrincipal = async (): Promise => {
+ const account = principalFor;
+ if (!account) return;
+ const request = {
+ name: principalName.trim(),
+ orgId: principalOrg.trim(),
+ isReadOnly: principalReadOnly,
+ bypassStepUp: principalBypass,
+ };
+ await run(async (proof) => {
+ await dcrypt.accounts.createPrincipal(account.itemId, request, proof);
+ setPrincipalName('');
+ setPrincipalOrg('');
+ setPrincipalFor(null);
+ return `Created ${request.name} — mint a key as it to give it credentials`;
});
};
@@ -200,6 +257,11 @@ export const AccountsScreen = () => {
{accounts.map((account) => {
const accountKeys = keys.filter((key) => key.accountItemId === account.itemId);
+ const accountPrincipals = principals[account.itemId] ?? [];
+ const mintedAs = (key: ApiKeyRecord): string =>
+ accountPrincipals.find(
+ (principal) => principal.principalId === key.principalId
+ )?.name ?? 'a principal';
return (
@@ -223,6 +285,14 @@ export const AccountsScreen = () => {
>
New API key
+
)}
+ {accountPrincipals.length > 0 && }
+ {accountPrincipals.map((principal) => (
+
+
+
+ {principal.name}
+ {principalReach(principal)}
+
+
+
+ ))}
+
{accountKeys.length > 0 && }
{accountKeys.map((key) => (
{key.name}
{expiry(key.expiresAt)}
+ {key.principalId && (
+
+ as {mintedAs(key)}
+
+ )}
+ {key.databaseId && (
+
+ {key.databaseId}
+
+ )}
+
+ {keyFor && (principals[keyFor.itemId] ?? []).length > 0 && (
+
+
+
+
+ {(principals[keyFor.itemId] ?? []).map((principal) => (
+
+ ))}
+
+
+ A key minted as a principal carries that principal's scope, not a
+ copy of your own access.
+
+
+ )}
+
+
+
setKeyDatabase(e.target.value)}
+ placeholder="database id"
+ className="font-mono"
+ />
+
+ Tag the key as that database's data-plane token, and dcrypt will serve
+ it to a harness asking for one.
+
+