Skip to content

feat(desktop): principals in the app — create one, see its scope, mint keys as it - #28

Merged
pyramation merged 1 commit into
mainfrom
feat/principals-ui
Aug 9, 2026
Merged

feat(desktop): principals in the app — create one, see its scope, mint keys as it#28
pyramation merged 1 commit into
mainfrom
feat/principals-ui

Conversation

@pyramation

Copy link
Copy Markdown
Contributor

Summary

#27 gave principals to the CLI only; this brings them to the Accounts screen, so a scoped sub-identity and a key minted as it are a couple of clicks rather than three commands.

Three new channels along the existing account pattern, and CreateKeyRequest gains the three fields the manager already accepted but the desktop dropped on the floor:

 interface CreateKeyRequest {
   name: string; expiresDays?: number; accessLevel?: string;
+  principalId?: string;   // mint the key *as* this principal
+  orgId?: string;         // an org key rather than a personal one
+  databaseId?: string;    // tag it as that database's data-plane token
 }
+accounts.principals(accountItemId)
+accounts.createPrincipal(accountItemId, request, stepUp?)
+accounts.deletePrincipal(accountItemId, principalId, stepUp?)
+accounts.assignKeyToDatabase(itemId, databaseId)

Principals are fetched per signed-in account on every refresh and never cached into the vault — a stale copy of someone's permissions reads as access they may no longer have. A fetch that fails yields [] for that account rather than blanking the screen, since a principal list is decoration next to the keys beside it.

Creation and deletion go through the existing run() closure, so the server's STEP_UP_REQUIRED holds and replays the identical request through the dialog already there — no second auth path.

principalReach() (src/shared/principal.ts, and the only part with tests, being the only part that is logic) puts a principal's scope into words. The case worth being careful about: an absent allowedMask means inherited, not empty, so it renders as mask inherited — "no restrictions recorded" and "no access" are opposites that otherwise look identical.

assignKeyToDatabase is exposed for keys minted before the tag existed: without it they can never answer dataToken(databaseId), so the harness would silently see nothing.

Desktop typecheck, lint and tests pass (44), as do the repo build, lint and full suite.

Link to Devin session: https://app.devin.ai/sessions/04636534e07048089ffb6b78142e12cd
Requested by: @pyramation

@pyramation pyramation self-assigned this Aug 9, 2026
@devin-ai-integration

Copy link
Copy Markdown

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

const [keyPrincipal, setKeyPrincipal] = useState<PrincipalRecord | null>(null);
const [keyDatabase, setKeyDatabase] = useState('');

const [tagFor, setTagFor] = useState<ApiKeyRecord | null>(null);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just realized, we're not using hooks which we could be using react-query plugin for constructive-io/graphql-codegen

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on the smell — this screen hand-rolls what a query cache does: useState per collection, a refresh() that refetches everything after every mutation, and a principals map keyed by account id that's really just N queries.

The one wrinkle is that the renderer never speaks GraphQL: the SDK lives in the main process (it holds the vault key and the session), and the renderer only sees IPC over the context bridge — so the generated hooks from the react-query plugin can't be dropped in here, because there's no client, no endpoint and no fetch on this side.

What does transfer is TanStack Query itself, with the IPC calls as query functions:

const { data: principals } = useQuery({
  queryKey: ['principals', account.itemId],
  queryFn: () => dcrypt.accounts.principals(account.itemId),
  enabled: account.signedIn,
});

const createKey = useMutation({
  mutationFn: (req: CreateKeyRequest) => dcrypt.accounts.createKey(account.itemId, req),
  onSuccess: () => queryClient.invalidateQueries({ queryKey: ['keys'] }),
});

That gets us per-account queries with their own loading/error state (rather than the catch { return [] } I have here), targeted invalidation instead of refetching the world, and no manual busy flag. The part that doesn't fall out for free is the step-up hold-and-replay, which needs the failed closure kept around — that would sit on top of mutate rather than inside it.

Worth doing as its own pass over the whole renderer rather than in this PR, I think, since VaultScreen and ItemDetail have the same shape. Want me to open a follow-up issue in constructive-planning for it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but I think we recently deleted it, so maybe we're stuck w this for now. unless we re-generate the hooks

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to regenerate anything for this, as it happens — the part that would help here isn't the generated hooks, it's plain TanStack Query with dcrypt.accounts.* as the query functions. Those hooks would have to be re-pointed away from a GraphQL client anyway, since the renderer has no endpoint to talk to; the auth calls all cross the IPC bridge to the main process, which is where the SDK and the vault key live.

So we're not blocked on the plugin: whenever we do the cache pass, @tanstack/react-query alone gets us the per-account queries and targeted invalidation. Worth keeping the generated hooks in mind for a renderer that does speak GraphQL directly — but that would mean handing it a token, which is the thing dcrypt is deliberately not doing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we generate a react page? we recently deleted it, but now I kinda want to try

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tempting, and I think there's a real experiment in it — but it needs the same wrinkle solved first: a generated page assumes a GraphQL client in the component tree, and this renderer deliberately has none (the SDK and the session token stay in the main process).

Two ways to try it that don't break that:

  1. Generate against the IPC bridge — same generator, different transport: emit hooks whose fetcher is dcrypt.accounts.* rather than a GraphQL request. Then a generated Principals page would work as-is here, and the pattern is reusable for anything else we put behind the bridge.
  2. Generate a throwaway page as a comparison — point it at a local endpoint, hold it next to AccountsScreen, and see how much of this hand-written screen it actually replaces (my guess: the tables and forms, not the step-up hold-and-replay or the reveal/copy paths, which are the security-shaped parts).

(2) is a couple of hours and tells us whether (1) is worth building. Happy to do it — but as its own branch, since this one is about landing principals. Which shape did you have in mind?

@pyramation
pyramation merged commit 64b54ee into main Aug 9, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant