Skip to content

Commit b407adc

Browse files
committed
UI
1 parent de5aa0a commit b407adc

7 files changed

Lines changed: 506 additions & 27 deletions

File tree

apps/desktop-ui/src/components/api-client/collections/use-collections.ts

Lines changed: 57 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,29 +8,16 @@ import { useAuthState } from "react-firebase-hooks/auth"
88
import { backendFetch } from "@/lib/backend-auth"
99
import { broadcastApiClientUpdate, useApiClientSyncListener } from "@/lib/api-client-sync"
1010
import { isDesktop } from "@/lib/desktop/is-desktop"
11-
import { diffFiles, newManifest, parseCollection, serializeCollection, MANIFEST_FILE } from "@/lib/api-client/file-store"
11+
import { countLiteralAuthSecrets, diffFiles, newManifest, parseCollection, serializeCollection, MANIFEST_FILE } from "@/lib/api-client/file-store"
12+
import { moveCollectionSecretsToVault } from "@/lib/api-client/move-to-vault"
13+
import { useCipherKey } from "@/lib/use-cipher-key"
1214

1315
const STORAGE_KEY = "api-client-collections"
14-
/** Registered folder-collection paths (desktop only — paths are machine-local). */
15-
const FILE_REGISTRY_KEY = "api-client-file-collections"
1616

1717
function sortCollections(cols: Collection[]) {
1818
return [...cols].sort((a, b) => a.name.localeCompare(b.name))
1919
}
2020

21-
function readFileRegistry(): string[] {
22-
try {
23-
const parsed = JSON.parse(localStorage.getItem(FILE_REGISTRY_KEY) ?? "[]")
24-
return Array.isArray(parsed) ? parsed.filter((p): p is string => typeof p === "string") : []
25-
} catch {
26-
return []
27-
}
28-
}
29-
30-
function writeFileRegistry(paths: string[]) {
31-
localStorage.setItem(FILE_REGISTRY_KEY, JSON.stringify(paths))
32-
}
33-
3421
/** Carry UI-only open/closed state (stripped from disk) across reloads, by folder id. */
3522
function applyOpenState(prev: Collection | undefined, next: Collection): Collection {
3623
if (!prev) return next
@@ -61,6 +48,12 @@ export function useCollections() {
6148
const migrationRanRef = React.useRef(false)
6249
/** Paths already toasted about, so a failing folder doesn't re-toast on every window focus. */
6350
const failedFilePathsRef = React.useRef(new Set<string>())
51+
/** Collections already warned about literal credentials — once per session is enough. */
52+
const secretWarnedRef = React.useRef(new Set<string>())
53+
const cipherKey = useCipherKey()
54+
// Latest file collections for toast-action callbacks (they outlive the closing render).
55+
const fileCollectionsRef = React.useRef(fileCollections)
56+
fileCollectionsRef.current = fileCollections
6457

6558
// DB collections + folder-backed collections, one list for consumers.
6659
const allCollections = React.useMemo(
@@ -71,12 +64,14 @@ export function useCollections() {
7164
/** (Re-)read every registered folder collection from disk. Desktop only. */
7265
const reloadFileCollections = React.useCallback(async () => {
7366
if (!isDesktop()) return
74-
const paths = readFileRegistry()
67+
const { readCollectionFiles, allowCollectionDir, loadCollectionRegistry } = await import(
68+
"@/lib/desktop/collection-files"
69+
)
70+
const paths = await loadCollectionRegistry()
7571
if (paths.length === 0) {
7672
setFileCollections((cur) => (cur.length === 0 ? cur : []))
7773
return
7874
}
79-
const { readCollectionFiles, allowCollectionDir } = await import("@/lib/desktop/collection-files")
8075
const loaded: Collection[] = []
8176
for (const path of paths) {
8277
try {
@@ -121,6 +116,22 @@ export function useCollections() {
121116
): Promise<boolean> => {
122117
const next = { ...target, ...patch, items: nextItems }
123118
setFileCollections((cur) => cur.map((c) => (c.id === target.id ? next : c)))
119+
// Literal credentials are redacted by the serializer (never written to git-able
120+
// files) — tell the user once so a "missing" token isn't a mystery, and offer
121+
// to move them into the encrypted vault (rewrites fields to {{vault.*}} tokens).
122+
if (!secretWarnedRef.current.has(target.id) && countLiteralAuthSecrets(next) > 0) {
123+
secretWarnedRef.current.add(target.id)
124+
toast.warning(
125+
"Credentials typed directly are not saved into collection files — keep them in the encrypted vault as {{vault.NAME}} tokens",
126+
{
127+
duration: 10000,
128+
action: {
129+
label: "Move to vault",
130+
onClick: () => void moveFileCollectionSecrets(target.id),
131+
},
132+
}
133+
)
134+
}
124135
try {
125136
const { writes, deletes } = diffFiles(serializeCollection(target), serializeCollection(next))
126137
if (writes.length > 0 || deletes.length > 0) {
@@ -136,6 +147,26 @@ export function useCollections() {
136147
}
137148
}
138149

150+
/** Toast action: move every literal credential in a folder collection into the vault. */
151+
const moveFileCollectionSecrets = async (collectionId: string) => {
152+
const target = fileCollectionsRef.current.find((c) => c.id === collectionId)
153+
if (!target) return
154+
try {
155+
const { collection: moved, moved: count } = await moveCollectionSecretsToVault(target, cipherKey)
156+
if (count === 0) return
157+
if (await mutateFileCollection(target, moved.items)) {
158+
toast.success(
159+
count === 1
160+
? "1 credential moved to the vault"
161+
: `${count} credentials moved to the vault`
162+
)
163+
}
164+
} catch (e) {
165+
console.error("Error moving credentials to vault", e)
166+
toast.error(e instanceof Error ? e.message : "Failed to move credentials to the vault")
167+
}
168+
}
169+
139170
React.useEffect(() => {
140171
if (!user) migrationRanRef.current = false
141172
}, [user])
@@ -325,7 +356,8 @@ export function useCollections() {
325356
// Deleting a folder collection = forget it, never touch the user's files.
326357
const fileCol = fileCollections.find((c) => c.id === itemId)
327358
if (fileCol?.source) {
328-
writeFileRegistry(readFileRegistry().filter((p) => p !== fileCol.source!.path))
359+
const { loadCollectionRegistry, saveCollectionRegistry } = await import("@/lib/desktop/collection-files")
360+
await saveCollectionRegistry((await loadCollectionRegistry()).filter((p) => p !== fileCol.source!.path))
329361
setFileCollections((cur) => cur.filter((c) => c.id !== itemId))
330362
toast.success("Folder collection removed from list (files kept)")
331363
return
@@ -705,7 +737,8 @@ export function useCollections() {
705737
const removedPaths = new Set(
706738
fileCollections.filter((c) => fileIds.has(c.id)).map((c) => c.source!.path)
707739
)
708-
writeFileRegistry(readFileRegistry().filter((p) => !removedPaths.has(p)))
740+
const { loadCollectionRegistry, saveCollectionRegistry } = await import("@/lib/desktop/collection-files")
741+
await saveCollectionRegistry((await loadCollectionRegistry()).filter((p) => !removedPaths.has(p)))
709742
setFileCollections((cur) => cur.filter((c) => !fileIds.has(c.id)))
710743
ids = ids.filter((id) => !fileIds.has(id))
711744
if (ids.length === 0) {
@@ -781,13 +814,12 @@ export function useCollections() {
781814
const openFolderCollection = async (): Promise<Collection | null> => {
782815
if (!isDesktop()) return null
783816
try {
784-
const { pickFolder, readCollectionFiles, writeCollectionFiles } = await import(
785-
"@/lib/desktop/collection-files"
786-
)
817+
const { pickFolder, readCollectionFiles, writeCollectionFiles, loadCollectionRegistry, saveCollectionRegistry } =
818+
await import("@/lib/desktop/collection-files")
787819
const path = await pickFolder()
788820
if (!path) return null
789821

790-
const registry = readFileRegistry()
822+
const registry = await loadCollectionRegistry()
791823
if (registry.includes(path)) {
792824
toast.info("This folder collection is already open")
793825
return fileCollections.find((c) => c.source?.path === path) ?? null
@@ -806,7 +838,7 @@ export function useCollections() {
806838
col = { id: manifest.id, name, items: [], source: { kind: "file", path } }
807839
}
808840

809-
writeFileRegistry([...registry, path])
841+
await saveCollectionRegistry([...registry, path])
810842
setFileCollections((cur) => [...cur.filter((c) => c.id !== col.id), col])
811843
toast.success(`Opened folder collection "${col.name}"`)
812844
return col

apps/desktop-ui/src/lib/api-client/__tests__/file-store.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
countLiteralAuthSecrets,
23
diffFiles,
34
parseCollection,
45
serializeCollection,
@@ -86,6 +87,81 @@ describe("file-store", () => {
8687
expect(all).toContain("{{vault.apiToken}}")
8788
})
8889

90+
test("literal credentials are redacted; vault tokens and non-secret fields survive", () => {
91+
const col: Collection = {
92+
id: "c",
93+
name: "c",
94+
items: [
95+
request({
96+
id: "r1",
97+
name: "AWS",
98+
auth: {
99+
type: "aws-sigv4",
100+
awsSigV4: {
101+
accessKeyId: "AKIA123",
102+
secretAccessKey: "SUPER-SECRET-LITERAL",
103+
region: "us-east-1",
104+
service: "s3",
105+
},
106+
},
107+
}),
108+
request({
109+
id: "r2",
110+
name: "Bearer literal",
111+
auth: { type: "bearer", token: "eyJhbGciOi-literal-jwt" },
112+
}),
113+
request({
114+
id: "r3",
115+
name: "Bearer vaulted",
116+
auth: { type: "bearer", token: "{{vault.apiToken}}" },
117+
}),
118+
request({
119+
id: "r4",
120+
name: "OAuth",
121+
auth: {
122+
type: "oauth2",
123+
oauth2: {
124+
grantType: "client_credentials",
125+
tokenUrl: "https://idp/token",
126+
clientId: "public-client-id",
127+
clientSecret: "literal-client-secret",
128+
accessToken: "cached-access-token",
129+
},
130+
},
131+
}),
132+
],
133+
}
134+
const all = serializeCollection(col).map((f) => f.content).join("\n")
135+
expect(all).not.toContain("SUPER-SECRET-LITERAL")
136+
expect(all).not.toContain("eyJhbGciOi-literal-jwt")
137+
expect(all).not.toContain("literal-client-secret")
138+
expect(all).not.toContain("cached-access-token")
139+
expect(all).toContain("AKIA123") // access key id is not the secret half
140+
expect(all).toContain("public-client-id")
141+
expect(all).toContain("{{vault.apiToken}}")
142+
expect(countLiteralAuthSecrets(col)).toBe(3)
143+
})
144+
145+
test("folder defaultAuth is redacted too", () => {
146+
const col: Collection = {
147+
id: "c",
148+
name: "c",
149+
items: [
150+
{
151+
id: "f",
152+
name: "F",
153+
type: "folder",
154+
items: [],
155+
defaultAuth: { type: "basic", username: "bob", password: "hunter2" },
156+
},
157+
],
158+
}
159+
const all = serializeCollection(col).map((f) => f.content).join("\n")
160+
expect(all).not.toContain("hunter2")
161+
expect(all).toContain("bob")
162+
expect(countLiteralAuthSecrets(col)).toBe(1)
163+
})
164+
89165
test("isOpen and KeyValueItem ids never reach disk", () => {
90166
const all = serializeCollection(collection()).map((f) => f.content).join("\n")
91167
expect(all).not.toContain("isOpen")
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { planVaultMoves } from "../move-to-vault"
2+
import type { Collection, CollectionFolder, CollectionRequest } from "@/components/api-client/types"
3+
4+
const request = (over: Partial<CollectionRequest>): CollectionRequest => ({
5+
id: "r",
6+
name: "Req",
7+
method: "GET",
8+
url: "https://x",
9+
params: [],
10+
headers: [],
11+
body: { type: "none", content: "" },
12+
auth: { type: "none" },
13+
...over,
14+
})
15+
16+
describe("planVaultMoves", () => {
17+
test("rewrites literals to {{vault.*}} tokens and plans creates", () => {
18+
const col: Collection = {
19+
id: "c",
20+
name: "c",
21+
items: [
22+
request({ id: "r1", name: "Login", auth: { type: "bearer", token: "literal-abc" } }),
23+
],
24+
}
25+
const plan = planVaultMoves(col, new Map())
26+
expect(plan.moved).toBe(1)
27+
expect(plan.creates).toEqual([{ name: "login-token", value: "literal-abc" }])
28+
expect((plan.collection.items[0] as CollectionRequest).auth.token).toBe("{{vault.login-token}}")
29+
// original untouched
30+
expect((col.items[0] as CollectionRequest).auth.token).toBe("literal-abc")
31+
})
32+
33+
test("reuses an existing vault entry holding the same value", () => {
34+
const col: Collection = {
35+
id: "c",
36+
name: "c",
37+
items: [request({ id: "r1", name: "Login", auth: { type: "bearer", token: "shared-secret" } })],
38+
}
39+
const plan = planVaultMoves(col, new Map([["prod-api-key", "shared-secret"]]))
40+
expect(plan.creates).toEqual([])
41+
expect(plan.moved).toBe(1)
42+
expect((plan.collection.items[0] as CollectionRequest).auth.token).toBe("{{vault.prod-api-key}}")
43+
})
44+
45+
test("suffixes when the name is taken by a different value; dedupes same value across requests", () => {
46+
const col: Collection = {
47+
id: "c",
48+
name: "c",
49+
items: [
50+
request({ id: "r1", name: "Login", auth: { type: "bearer", token: "new-secret" } }),
51+
request({ id: "r2", name: "Login", auth: { type: "bearer", token: "new-secret" } }),
52+
],
53+
}
54+
const plan = planVaultMoves(col, new Map([["login-token", "different-value"]]))
55+
expect(plan.creates).toEqual([{ name: "login-token-2", value: "new-secret" }])
56+
expect(plan.moved).toBe(2)
57+
const tokens = plan.collection.items.map((i) => (i as CollectionRequest).auth.token)
58+
expect(tokens).toEqual(["{{vault.login-token-2}}", "{{vault.login-token-2}}"])
59+
})
60+
61+
test("covers folder defaultAuth and nested fields; leaves tokens alone", () => {
62+
const folder: CollectionFolder = {
63+
id: "f",
64+
name: "AWS stuff",
65+
type: "folder",
66+
items: [
67+
request({
68+
id: "r1",
69+
name: "S3",
70+
auth: {
71+
type: "aws-sigv4",
72+
awsSigV4: {
73+
accessKeyId: "AKIA1",
74+
secretAccessKey: "aws-literal",
75+
region: "us-east-1",
76+
service: "s3",
77+
},
78+
},
79+
}),
80+
request({ id: "r2", name: "Vaulted", auth: { type: "bearer", token: "{{vault.ok}}" } }),
81+
],
82+
defaultAuth: { type: "basic", username: "bob", password: "hunter2" },
83+
}
84+
const col: Collection = { id: "c", name: "c", items: [folder] }
85+
const plan = planVaultMoves(col, new Map())
86+
expect(plan.creates.map((c) => c.name).sort()).toEqual(["aws-stuff-password", "s3-secretaccesskey"])
87+
expect(plan.moved).toBe(2)
88+
const outFolder = plan.collection.items[0] as CollectionFolder
89+
expect(outFolder.defaultAuth?.password).toBe("{{vault.aws-stuff-password}}")
90+
const s3 = outFolder.items[0] as CollectionRequest
91+
expect(s3.auth.awsSigV4?.secretAccessKey).toBe("{{vault.s3-secretaccesskey}}")
92+
expect(s3.auth.awsSigV4?.accessKeyId).toBe("AKIA1")
93+
expect((outFolder.items[1] as CollectionRequest).auth.token).toBe("{{vault.ok}}")
94+
})
95+
})

0 commit comments

Comments
 (0)