1- import { useState } from "react" ;
2- import { useAtomSet , useAtomValue } from "@effect/atom-react" ;
1+ import { useEffect , useRef , useState } from "react" ;
2+ import { useAtomRefresh , useAtomSet , useAtomValue } from "@effect/atom-react" ;
33import * as Exit from "effect/Exit" ;
44import * as AsyncResult from "effect/unstable/reactivity/AsyncResult" ;
55import { Button } from "@executor-js/react/components/button" ;
6+ import { Checkbox } from "@executor-js/react/components/checkbox" ;
67import { Input } from "@executor-js/react/components/input" ;
78import { Label } from "@executor-js/react/components/label" ;
89import {
@@ -35,10 +36,10 @@ import {
3536 removeOnePasswordConfig ,
3637 onepasswordWriteKeys ,
3738} from "./atoms" ;
38- import type { RedactedOnePasswordConfig } from "../sdk/types" ;
39+ import type { RedactedOnePasswordConfig , Vault } from "../sdk/types" ;
3940
4041// ---------------------------------------------------------------------------
41- // Vault picker
42+ // Vault picker — multi-select
4243// ---------------------------------------------------------------------------
4344
4445const VAULT_LIST_ERROR_FALLBACK = "Failed to list vaults" ;
@@ -52,11 +53,24 @@ const formatVaultListError = (error: Error): string => {
5253function VaultPicker ( props : {
5354 authKind : "desktop-app" | "service-account" ;
5455 accountName : string ;
55- vaultId : string ;
56- onVaultSelect : ( id : string , name : string ) => void ;
56+ selected : ReadonlyArray < Vault > ;
57+ onSelectedChange : ( vaults : ReadonlyArray < Vault > ) => void ;
5758} ) {
5859 const account = props . accountName . trim ( ) ;
59- const vaultsResult = useAtomValue ( onepasswordVaultsAtom ( props . authKind , account ) ) ;
60+ const vaultsAtom = onepasswordVaultsAtom ( props . authKind , account ) ;
61+ const vaultsResult = useAtomValue ( vaultsAtom ) ;
62+ const refreshVaults = useAtomRefresh ( vaultsAtom ) ;
63+
64+ // Stale-while-revalidate: with a retained value the vault list renders
65+ // instantly and one background refresh per atom key picks up changes
66+ // (refreshing keeps the previous value, so nothing flashes). A cold key is
67+ // already fetching — refreshing it would only restart the request. The ref
68+ // carries the latest cached-ness into the effect without re-running it.
69+ const isCachedRef = useRef ( false ) ;
70+ isCachedRef . current = AsyncResult . isSuccess ( vaultsResult ) ;
71+ useEffect ( ( ) => {
72+ if ( isCachedRef . current ) refreshVaults ( ) ;
73+ } , [ refreshVaults ] ) ;
6074
6175 const { vaults, isLoading, error } = AsyncResult . matchWithError (
6276 vaultsResult as AsyncResult . AsyncResult <
@@ -81,12 +95,9 @@ function VaultPicker(props: {
8195 } ) ,
8296 onSuccess : ( { value } ) => {
8397 const v = value . vaults ;
84- const defaultVault = v [ 0 ] ;
85- if (
86- defaultVault &&
87- ( ! props . vaultId || ( v . length === 1 && props . vaultId !== defaultVault . id ) )
88- ) {
89- queueMicrotask ( ( ) => props . onVaultSelect ( defaultVault . id , defaultVault . name ) ) ;
98+ const onlyVault = v . length === 1 ? v [ 0 ] : undefined ;
99+ if ( onlyVault && props . selected . length === 0 ) {
100+ queueMicrotask ( ( ) => props . onSelectedChange ( [ onlyVault ] ) ) ;
90101 }
91102 return { vaults : [ ...v ] , isLoading : false , error : null } ;
92103 } ,
@@ -101,34 +112,51 @@ function VaultPicker(props: {
101112 ) ;
102113 }
103114
104- const singleVault = vaults . length === 1 ? vaults [ 0 ] : null ;
115+ // Selected vaults missing from the loaded list (renamed, revoked, or the
116+ // list failed to load while editing) stay visible so they can be unchecked.
117+ const loadedIds = new Set ( vaults . map ( ( v ) => v . id ) ) ;
118+ const stale = props . selected . filter ( ( v ) => ! loadedIds . has ( v . id ) ) ;
119+ const rows = [ ...vaults , ...stale ] ;
120+
121+ const toggle = ( vault : Vault , checked : boolean ) => {
122+ if ( checked ) {
123+ if ( ! props . selected . some ( ( v ) => v . id === vault . id ) ) {
124+ props . onSelectedChange ( [ ...props . selected , vault ] ) ;
125+ }
126+ return ;
127+ }
128+ props . onSelectedChange ( props . selected . filter ( ( v ) => v . id !== vault . id ) ) ;
129+ } ;
105130
106131 return (
107132 < div className = "grid gap-2" >
108- { singleVault ? (
109- < div className = "flex h-9 items-center rounded-md border border-input bg-muted/30 px-3 text-[13px ] text-foreground" >
110- < span className = "truncate" > { singleVault . name } </ span >
111- </ div >
133+ { isLoading ? (
134+ < p className = "text-[11px ] text-muted- foreground/50 py-1" > Loading vaults… </ p >
135+ ) : rows . length === 0 ? (
136+ < p className = "text-[11px] text-muted-foreground/50 py-1" > No vaults found. </ p >
112137 ) : (
113- < Select
114- disabled = { isLoading || vaults . length === 0 }
115- value = { props . vaultId }
116- onValueChange = { ( id ) => {
117- const v = vaults . find ( ( vault ) => vault . id === id ) ;
118- if ( v ) props . onVaultSelect ( v . id , v . name ) ;
119- } }
120- >
121- < SelectTrigger className = "h-9 text-[13px]" >
122- < SelectValue placeholder = { isLoading ? "Loading…" : "Select a vault" } />
123- </ SelectTrigger >
124- < SelectContent >
125- { vaults . map ( ( v ) => (
126- < SelectItem key = { v . id } value = { v . id } >
127- { v . name }
128- </ SelectItem >
129- ) ) }
130- </ SelectContent >
131- </ Select >
138+ < div className = "grid max-h-44 gap-0.5 overflow-y-auto rounded-md border border-input p-1" >
139+ { rows . map ( ( vault ) => {
140+ const checked = props . selected . some ( ( v ) => v . id === vault . id ) ;
141+ return (
142+ < Label
143+ key = { vault . id }
144+ className = "flex cursor-pointer items-center gap-2.5 rounded-sm px-2 py-1.5 font-normal hover:bg-muted/40"
145+ >
146+ < Checkbox
147+ checked = { checked }
148+ onCheckedChange = { ( value ) => toggle ( vault , value === true ) }
149+ />
150+ < span className = "truncate text-[13px] text-foreground" > { vault . name } </ span >
151+ { ! loadedIds . has ( vault . id ) && (
152+ < span className = "ml-auto shrink-0 text-[11px] text-muted-foreground/50" >
153+ not found
154+ </ span >
155+ ) }
156+ </ Label >
157+ ) ;
158+ } ) }
159+ </ div >
132160 ) }
133161 { error && (
134162 < div className = "rounded-md border border-destructive/20 bg-destructive/5 px-2.5 py-1.5" >
@@ -151,7 +179,7 @@ function ConfigDialog(props: {
151179 initial ?: {
152180 authKind : string ;
153181 accountName : string ;
154- vaultId : string ;
182+ vaults : ReadonlyArray < Vault > ;
155183 name : string ;
156184 } ;
157185} ) {
@@ -160,8 +188,10 @@ function ConfigDialog(props: {
160188 ( props . initial ?. authKind as "desktop-app" | "service-account" ) ?? "desktop-app" ,
161189 ) ;
162190 const [ accountName , setAccountName ] = useState ( props . initial ?. accountName ?? "my.1password.com" ) ;
163- const [ vaultId , setVaultId ] = useState ( props . initial ?. vaultId ?? "" ) ;
164- const [ vaultName , setVaultName ] = useState ( props . initial ?. name ?? "" ) ;
191+ const [ selectedVaults , setSelectedVaults ] = useState < ReadonlyArray < Vault > > (
192+ props . initial ?. vaults ?? [ ] ,
193+ ) ;
194+ const [ displayName , setDisplayName ] = useState ( props . initial ?. name ?? "" ) ;
165195 const [ saving , setSaving ] = useState ( false ) ;
166196 const [ error , setError ] = useState < string | null > ( null ) ;
167197
@@ -171,15 +201,16 @@ function ConfigDialog(props: {
171201 if ( ! isEdit ) {
172202 setAuthKind ( "desktop-app" ) ;
173203 setAccountName ( "my.1password.com" ) ;
174- setVaultId ( "" ) ;
175- setVaultName ( "" ) ;
204+ setSelectedVaults ( [ ] ) ;
205+ setDisplayName ( "" ) ;
176206 }
177207 setError ( null ) ;
178208 setSaving ( false ) ;
179209 } ;
180210
181211 const handleSave = async ( ) => {
182- if ( ! accountName . trim ( ) || ! vaultId . trim ( ) ) return ;
212+ const [ firstVault , ...restVaults ] = selectedVaults ;
213+ if ( ! accountName . trim ( ) || firstVault === undefined ) return ;
183214 setSaving ( true ) ;
184215 setError ( null ) ;
185216
@@ -191,8 +222,8 @@ function ConfigDialog(props: {
191222 const exit = await doConfigure ( {
192223 payload : {
193224 auth,
194- vaultId : vaultId . trim ( ) ,
195- name : vaultName . trim ( ) || "1Password" ,
225+ vaults : [ firstVault , ... restVaults ] ,
226+ name : displayName . trim ( ) || "1Password" ,
196227 } ,
197228 reactivityKeys : onepasswordWriteKeys ,
198229 } ) ;
@@ -220,7 +251,8 @@ function ConfigDialog(props: {
220251 { isEdit ? "Edit 1Password" : "Connect 1Password" }
221252 </ DialogTitle >
222253 < DialogDescription className = "text-[13px] leading-relaxed" >
223- Link a vault to resolve secrets via the 1Password desktop app or a service account.
254+ Link one or more vaults to resolve secrets via the 1Password desktop app or a service
255+ account.
224256 </ DialogDescription >
225257 </ DialogHeader >
226258
@@ -262,19 +294,16 @@ function ConfigDialog(props: {
262294 </ p >
263295 </ div >
264296
265- { /* Vault */ }
297+ { /* Vaults */ }
266298 < div className = "grid gap-1.5" >
267299 < Label className = "text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground" >
268- Vault
300+ Vaults
269301 </ Label >
270302 < VaultPicker
271303 authKind = { authKind }
272304 accountName = { accountName }
273- vaultId = { vaultId }
274- onVaultSelect = { ( id , name ) => {
275- setVaultId ( id ) ;
276- setVaultName ( name ) ;
277- } }
305+ selected = { selectedVaults }
306+ onSelectedChange = { setSelectedVaults }
278307 />
279308 </ div >
280309
@@ -285,8 +314,8 @@ function ConfigDialog(props: {
285314 </ Label >
286315 < Input
287316 placeholder = "1Password"
288- value = { vaultName }
289- onChange = { ( e ) => setVaultName ( ( e . target as HTMLInputElement ) . value ) }
317+ value = { displayName }
318+ onChange = { ( e ) => setDisplayName ( ( e . target as HTMLInputElement ) . value ) }
290319 className = "text-[13px] h-9"
291320 />
292321 </ div >
@@ -307,7 +336,7 @@ function ConfigDialog(props: {
307336 < Button
308337 size = "sm"
309338 onClick = { handleSave }
310- disabled = { ! accountName . trim ( ) || ! vaultId . trim ( ) || saving }
339+ disabled = { ! accountName . trim ( ) || selectedVaults . length === 0 || saving }
311340 >
312341 { saving ? "Saving…" : isEdit ? "Update" : "Connect" }
313342 </ Button >
@@ -371,14 +400,18 @@ export default function OnePasswordSettings() {
371400 < span className = "font-mono text-foreground/80 truncate" >
372401 { config . auth . kind === "desktop-app" ? config . auth . accountName : "service-account" }
373402 </ span >
374- < span className = "text-muted-foreground/60" > Vault</ span >
403+ < span className = "text-muted-foreground/60" >
404+ { config . vaults . length === 1 ? "Vault" : "Vaults" }
405+ </ span >
375406 < div className = "flex items-center gap-2 min-w-0" >
376- < span className = "text-foreground/80 truncate" > { config . name } </ span >
407+ < span className = "text-foreground/80 truncate" >
408+ { config . vaults . map ( ( vault ) => vault . name ) . join ( ", " ) }
409+ </ span >
377410 </ div >
378411 </ div >
379412 ) : (
380413 < CardStackEntryDescription >
381- Resolve secrets from your 1Password vault .
414+ Resolve secrets from your 1Password vaults .
382415 </ CardStackEntryDescription >
383416 ) }
384417 </ CardStackEntryContent >
@@ -429,7 +462,7 @@ export default function OnePasswordSettings() {
429462 // Service-account tokens are never surfaced (redacted); the
430463 // user re-enters the token when editing that auth method.
431464 accountName : config . auth . kind === "desktop-app" ? config . auth . accountName : "" ,
432- vaultId : config . vaultId ,
465+ vaults : config . vaults ,
433466 name : config . name ,
434467 }
435468 : undefined
0 commit comments