[ui] store storage credentials per provider so R2 / B2 / S3 don't clash

The Storage pane saved every provider into a single shared "default" connection id,
so configuring Backblaze B2 overwrote your Cloudflare R2 (and vice-versa). The service
layer, upload path, and sync already supported a MAP of connections keyed by id
(listConnections / defaultForUploads / exportAccountRecord) — only the UI collapsed
them into one slot.

- Save each provider under its own slot keyed by provider id (cloudflare_r2 /
  backblaze_b2 / generic_s3), preserving a single active defaultForUploads and lazily
  migrating the legacy "default" slot to its provider id.
- Switching the provider dropdown now loads that provider's saved credentials (or
  clears the fields for a fresh entry), so each provider keeps its own creds visibly.
- Sync is unchanged: it already exports the whole account record, so every provider's
  connection syncs together.

Logic extracted to js/ui/profileModal/storageConnections.js (saveProviderConnection +
pure helpers) so ProfileStorageController stays under the 1000-line cap. Tests:
tests/storage-connections.test.mjs (7 — pure default/dup logic + integration proving
R2+B2 coexist with one default, the re-default switch, legacy-"default" migration, and
that the synced record carries both). Storage + profile-modal suites 69/69; lint clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-30 10:58:30 -04:00
1 parent 6006813b7e
commit f0beab2713
4 files changed
+366 -53

No files matched your search

+40 -53
View File
@@ -9,6 +9,12 @@ import { getActiveSigner } from "../../nostr/client.js";
import { DEFAULT_NIP07_ENCRYPTION_METHODS } from "../../nostr/nip07Permissions.js";
import { storageSyncService } from "../../services/storageSyncService.js";
import { StorageCorsHelp } from "./storageCorsHelp.js";
import {
fillStorageForm,
clearCredentialFields,
findProviderConnection,
saveProviderConnection,
} from "./storageConnections.js";
export class ProfileStorageController {
constructor(mainController) {
@@ -111,7 +117,7 @@ export class ProfileStorageController {
if (this.storageProviderInput instanceof HTMLElement) {
this.storageProviderInput.addEventListener("change", () => {
this.updateStorageFormVisibility();
void this.handleProviderChange();
});
}
@@ -212,7 +218,7 @@ export class ProfileStorageController {
targetConn.id
);
if (conn) {
this.fillStorageForm(conn);
fillStorageForm(this, conn);
if (this.storageStatusText) {
const label = conn.meta?.label || conn.provider || "S3";
this.storageStatusText.textContent = `Unlocked (${label})`;
@@ -355,57 +361,32 @@ export class ProfileStorageController {
}
}
fillStorageForm(conn) {
if (!conn) return;
const {
provider,
accessKeyId,
secretAccessKey,
accountId: payloadAccountId,
endpoint: payloadEndpoint,
forcePathStyle: payloadForcePathStyle,
} = conn;
const {
endpoint,
region,
bucket,
prefix,
defaultForUploads,
accountId,
forcePathStyle: metaForcePathStyle,
} = conn.meta || {};
if (this.storageProviderInput)
this.storageProviderInput.value = provider || "cloudflare_r2";
const resolvedEndpoint =
endpoint || accountId || payloadAccountId || payloadEndpoint || "";
if (this.storageEndpointInput)
this.storageEndpointInput.value = resolvedEndpoint;
if (this.storageRegionInput)
this.storageRegionInput.value = region || "auto";
if (this.storageAccessKeyInput)
this.storageAccessKeyInput.value = accessKeyId || "";
if (this.storageSecretKeyInput)
this.storageSecretKeyInput.value = secretAccessKey || "";
if (this.storageBucketInput) this.storageBucketInput.value = bucket || "";
if (this.storagePrefixInput) this.storagePrefixInput.value = prefix || "";
if (this.storageDefaultInput)
this.storageDefaultInput.checked = !!defaultForUploads;
if (this.storageForcePathStyleInput) {
if (typeof payloadForcePathStyle === "boolean") {
this.storageForcePathStyleInput.checked = payloadForcePathStyle;
} else if (typeof metaForcePathStyle === "boolean") {
this.storageForcePathStyleInput.checked = metaForcePathStyle;
} else {
this.storageForcePathStyleInput.checked = true;
}
}
// Load the saved connection for the newly-selected provider so each provider keeps
// its own credentials; clear the credential fields when that provider has none yet.
async handleProviderChange() {
this.updateStorageFormVisibility();
this.handlePublicUrlInput();
const pubkey = this.mainController.normalizeHexPubkey(
this.mainController.getActivePubkey(),
);
const storageService = this.mainController.services?.storageService;
if (!pubkey || !storageService || !storageService.isUnlocked?.(pubkey)) {
return;
}
const provider = this.storageProviderInput?.value || "cloudflare_r2";
try {
const connections = await storageService.listConnections(pubkey);
const match = findProviderConnection(connections, provider);
if (match) {
const conn = await storageService.getConnection(pubkey, match.id);
if (conn) {
fillStorageForm(this, conn);
return;
}
}
clearCredentialFields(this);
} catch (error) {
devLogger.warn("[ProfileModal] Failed to load connection for provider:", error);
}
}
handlePublicUrlInput() {
@@ -858,7 +839,13 @@ export class ProfileStorageController {
this.setStorageFormStatus("Saving...", "info");
try {
await storageService.saveConnection(pubkey, "default", payload, meta);
// One slot per provider type so providers don't overwrite or clash.
await saveProviderConnection(storageService, pubkey, {
provider,
payload,
meta,
isDefault,
});
this.setStorageFormStatus("Connection saved.", "success");
this.mainController.showSuccess("Storage connection saved.");