fix(sources): show saved account configuration

This commit is contained in:
2026-08-06 21:10:50 -04:00
parent e265f25fcf
commit 6eafc96d9b
7 changed files with 305 additions and 14 deletions
@@ -1,5 +1,6 @@
using System.Text.Json;
using allstarr.Controllers;
using allstarr.Core.Capabilities;
using allstarr.Core.Identity;
using allstarr.Core.Operations;
using allstarr.Core.Secrets;
@@ -118,6 +119,52 @@ public sealed class ProviderAccountsControllerTests : IAsyncLifetime
Assert.False(accounts[0].GetProperty("secret").TryGetProperty("value", out _));
}
[Fact]
public async Task ExtensionConfiguration_ReturnsSafeValuesAndPreservesBlankSecretFields()
{
var registry = AppleExtensionRegistry();
var controller = Controller(Session(_userId), providerRegistry: registry);
using var secret = JsonDocument.Parse(
"""{"storefront":"ca","mediaUserToken":"fixture-private-token","lyricsTranslationLanguage":"es"}""");
var created = Assert.IsType<CreatedAtActionResult>(await controller.Create(
new ProviderAccountsController.CreateProviderAccountRequest
{
ProviderId = "spotiflac-apple-music",
DisplayName = "Apple Music",
Scope = "User",
Secret = secret.RootElement.Clone()
}));
using var createdJson = JsonDocument.Parse(JsonSerializer.Serialize(created.Value));
var accountId = createdJson.RootElement.GetProperty("Id").GetGuid();
var listed = Assert.IsType<OkObjectResult>(await controller.List());
using var listedJson = JsonDocument.Parse(JsonSerializer.Serialize(listed.Value));
var account = listedJson.RootElement.GetProperty("accounts")[0];
Assert.Equal("ca", account.GetProperty("configuration").GetProperty("storefront").GetString());
Assert.Equal("es", account.GetProperty("configuration").GetProperty("lyricsTranslationLanguage").GetString());
Assert.Contains(account.GetProperty("configuredFields").EnumerateArray(),
item => item.GetString() == "mediaUserToken");
Assert.DoesNotContain("fixture-private-token", listedJson.RootElement.GetRawText(), StringComparison.Ordinal);
using var replacement = JsonDocument.Parse(
"""{"storefront":"jp","mediaUserToken":"","lyricsTranslationLanguage":"es"}""");
Assert.IsType<OkObjectResult>(await controller.ReplaceSecret(
accountId,
new ProviderAccountsController.ReplaceProviderSecretRequest
{
Secret = replacement.RootElement.Clone()
}));
await using var context = await _factory.CreateDbContextAsync();
var persisted = await context.ProviderAccounts.SingleAsync(item => item.Id == accountId);
using var lease = await _secretStore.OpenAsync(
persisted.SecretReferenceId!.Value,
new SecretAccessContext(_tenantId));
using var saved = JsonDocument.Parse(lease.Value);
Assert.Equal("jp", saved.RootElement.GetProperty("storefront").GetString());
Assert.Equal("fixture-private-token", saved.RootElement.GetProperty("mediaUserToken").GetString());
}
[Fact]
public async Task ImportedDisabledAccount_CanBeEnabledWithoutReplacingItsCredential()
{
@@ -424,7 +471,8 @@ public sealed class ProviderAccountsControllerTests : IAsyncLifetime
private ProviderAccountsController Controller(
AdminAuthSession session,
ProviderAccountManagementMode mode = ProviderAccountManagementMode.Hybrid)
ProviderAccountManagementMode mode = ProviderAccountManagementMode.Hybrid,
IProviderRegistry? providerRegistry = null)
{
var context = new DefaultHttpContext();
context.TraceIdentifier = Guid.NewGuid().ToString("N");
@@ -433,12 +481,41 @@ public sealed class ProviderAccountsControllerTests : IAsyncLifetime
_factory,
_secretStore,
_cache,
new ProviderAccountManagementOptions { ManagementMode = mode.ToString() })
new ProviderAccountManagementOptions { ManagementMode = mode.ToString() },
providerRegistry)
{
ControllerContext = new ControllerContext { HttpContext = context }
};
}
private static IProviderRegistry AppleExtensionRegistry() => new ProviderRegistry([
new ProviderRegistration(new ProviderDescriptor(
"spotiflac-apple-music",
"Apple Music",
"Apple Music metadata and lyrics",
ProviderOrigin.Extension,
"1",
"1",
[new ProviderCapabilityDescriptor(
ProviderCapabilityKind.Metadata,
ProviderCapabilitySupportState.ConfiguredOnly,
ProviderAccountRequirement.None,
"1")],
new ProviderPermissionDescriptor(secretSettingKeys: ["mediaUserToken"]),
[
new ProviderSettingDescriptor(
"storefront", ProviderSettingValueKind.Text, ProviderSettingScope.ProviderAccount,
"Storefront", defaultJson: "\"us\""),
new ProviderSettingDescriptor(
"mediaUserToken", ProviderSettingValueKind.Secret, ProviderSettingScope.ProviderAccount,
"Media User Token"),
new ProviderSettingDescriptor(
"lyricsTranslationLanguage", ProviderSettingValueKind.Text,
ProviderSettingScope.ProviderAccount, "Lyrics Translation Language")
],
entryPoint: "index.js"))
]);
private AdminAuthSession Session(Guid userId, bool administrator = false) => new()
{
SessionId = Guid.NewGuid().ToString("N"),
@@ -1,6 +1,7 @@
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using allstarr.Core.Capabilities;
using allstarr.Core.Identity;
using allstarr.Core.Secrets;
using allstarr.Core.Storage;
@@ -22,17 +23,20 @@ public sealed partial class ProviderAccountsController : ControllerBase
private readonly EncryptedSecretStore _secretStore;
private readonly IApplicationCache _cache;
private readonly ProviderAccountManagementMode _managementMode;
private readonly IProviderRegistry? _providerRegistry;
public ProviderAccountsController(
IDbContextFactory<AllstarrDbContext> contextFactory,
EncryptedSecretStore secretStore,
IApplicationCache cache,
ProviderAccountManagementOptions managementOptions)
ProviderAccountManagementOptions managementOptions,
IProviderRegistry? providerRegistry = null)
{
_contextFactory = contextFactory;
_secretStore = secretStore;
_cache = cache;
_managementMode = managementOptions.ParseManagementMode();
_providerRegistry = providerRegistry;
}
[HttpGet]
@@ -81,6 +85,12 @@ public sealed partial class ProviderAccountsController : ControllerBase
.Select(item => new { item.Id, item.DisplayName })
.ToListAsync(cancellationToken)
: [];
var configurations = await ReadAccountConfigurationsAsync(
accounts.Where(account => account.SecretReferenceId.HasValue &&
secrets.TryGetValue(account.SecretReferenceId.Value, out var secret) &&
!secret.RevokedAt.HasValue)
.ToList(),
cancellationToken);
return Ok(new
{
managementMode = _managementMode.ToString(),
@@ -92,7 +102,8 @@ public sealed partial class ProviderAccountsController : ControllerBase
? secret
: null,
account.OwnerUserId.HasValue ? users.GetValueOrDefault(account.OwnerUserId.Value) : null,
account.CreatedByUserId.HasValue ? users.GetValueOrDefault(account.CreatedByUserId.Value) : null))
account.CreatedByUserId.HasValue ? users.GetValueOrDefault(account.CreatedByUserId.Value) : null,
configurations.GetValueOrDefault(account.Id)))
});
}
@@ -256,7 +267,7 @@ public sealed partial class ProviderAccountsController : ControllerBase
return NotFound();
}
var bytes = Encoding.UTF8.GetBytes(request.Secret.GetRawText());
var bytes = await MergeProviderSecretAsync(account, request.Secret, cancellationToken);
var secret = await _secretStore.StoreAsync(
account.TenantId,
$"provider-account:{account.ProviderId}:{account.Id:N}",
@@ -655,7 +666,8 @@ public sealed partial class ProviderAccountsController : ControllerBase
ProviderAccountRecord account,
SecretReferenceRecord? secret,
string? ownerDisplayName = null,
string? creatorDisplayName = null) => new
string? creatorDisplayName = null,
AccountConfigurationSummary? configuration = null) => new
{
account.Id,
account.ProviderId,
@@ -670,6 +682,8 @@ public sealed partial class ProviderAccountsController : ControllerBase
account.LibraryScopeId,
account.Enabled,
account.Revision,
configuration = configuration?.Values ?? new Dictionary<string, JsonElement>(),
configuredFields = configuration?.ConfiguredFields ?? [],
secret = new
{
configured = account.SecretReferenceId.HasValue,
@@ -681,6 +695,99 @@ public sealed partial class ProviderAccountsController : ControllerBase
account.UpdatedAt
};
private async Task<Dictionary<Guid, AccountConfigurationSummary>> ReadAccountConfigurationsAsync(
IReadOnlyList<ProviderAccountRecord> accounts,
CancellationToken cancellationToken)
{
var summaries = new Dictionary<Guid, AccountConfigurationSummary>();
if (_providerRegistry == null) return summaries;
foreach (var account in accounts.Where(item => item.SecretReferenceId.HasValue))
{
if (!_providerRegistry.TryGet(account.ProviderId, out var provider) ||
provider == null || provider.Settings.Count == 0)
{
continue;
}
using var lease = await _secretStore.OpenAsync(
account.SecretReferenceId!.Value,
SecretAccess(account),
cancellationToken);
using var document = JsonDocument.Parse(lease.Value);
if (document.RootElement.ValueKind != JsonValueKind.Object) continue;
var values = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
var configured = new List<string>();
foreach (var setting in provider.Settings)
{
if (!document.RootElement.TryGetProperty(setting.Key, out var value) || !HasValue(value)) continue;
configured.Add(setting.Key);
if (setting.ValueKind != ProviderSettingValueKind.Secret)
values[setting.Key] = value.Clone();
}
summaries[account.Id] = new AccountConfigurationSummary(values, configured);
}
return summaries;
}
private async Task<byte[]> MergeProviderSecretAsync(
ProviderAccountRecord account,
JsonElement replacement,
CancellationToken cancellationToken)
{
if (replacement.ValueKind != JsonValueKind.Object ||
!account.SecretReferenceId.HasValue ||
_providerRegistry == null ||
!_providerRegistry.TryGet(account.ProviderId, out var provider) ||
provider == null)
{
return Encoding.UTF8.GetBytes(replacement.GetRawText());
}
var secretKeys = provider.Settings
.Where(item => item.ValueKind == ProviderSettingValueKind.Secret)
.Select(item => item.Key)
.ToArray();
if (secretKeys.Length == 0) return Encoding.UTF8.GetBytes(replacement.GetRawText());
using var lease = await _secretStore.OpenAsync(
account.SecretReferenceId.Value,
SecretAccess(account),
cancellationToken);
using var current = JsonDocument.Parse(lease.Value);
if (current.RootElement.ValueKind != JsonValueKind.Object)
return Encoding.UTF8.GetBytes(replacement.GetRawText());
var values = replacement.EnumerateObject()
.ToDictionary(item => item.Name, item => item.Value.Clone(), StringComparer.Ordinal);
foreach (var key in secretKeys)
{
if ((!values.TryGetValue(key, out var value) || !HasValue(value)) &&
current.RootElement.TryGetProperty(key, out var existing))
{
values[key] = existing.Clone();
}
}
return JsonSerializer.SerializeToUtf8Bytes(values);
}
private static SecretAccessContext SecretAccess(ProviderAccountRecord account) =>
account.Scope == ProviderAccountScope.Global
? new SecretAccessContext(null, AllowGlobal: true)
: new SecretAccessContext(account.TenantId);
private static bool HasValue(JsonElement value) =>
value.ValueKind != JsonValueKind.Null &&
(value.ValueKind != JsonValueKind.String || !string.IsNullOrWhiteSpace(value.GetString()));
private sealed record AccountConfigurationSummary(
IReadOnlyDictionary<string, JsonElement> Values,
IReadOnlyList<string> ConfiguredFields);
private static string SourceDisplayName(ProviderAccountRecord account, string? creatorDisplayName)
{
var name = FriendlyDisplayName(account);
+46
View File
@@ -4963,6 +4963,52 @@
border-radius: var(--radius-md);
}
.source-account-configurations {
display: grid;
gap: var(--space-4);
margin-top: var(--space-4);
}
.source-account-configuration {
overflow: hidden;
border: 1px solid var(--color-edge);
border-radius: var(--radius-md);
background: var(--color-panel-raised);
}
.source-account-configuration > header,
.source-account-configuration > footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3);
}
.source-account-configuration > header {
border-bottom: 1px solid var(--color-edge);
}
.source-account-configuration > header > span:first-child {
display: grid;
gap: var(--space-1);
}
.source-account-configuration > header small,
.source-account-configuration .source-detail-data small {
color: var(--color-ink-muted);
font-size: var(--text-xs);
}
.source-account-configuration .source-detail-data {
padding: var(--space-3);
}
.source-account-configuration > footer {
justify-content: flex-end;
border-top: 1px solid var(--color-edge);
}
.source-detail-capabilities > span {
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
+2
View File
@@ -386,6 +386,8 @@ export type ProviderAccount = {
libraryScopeId?: string | null;
enabled: boolean;
revision: number;
configuration?: Record<string, unknown>;
configuredFields?: string[];
secret: {
configured: boolean;
version?: number | null;
@@ -31,6 +31,9 @@
choices.find((provider) => provider.id === (account?.providerId ?? providerId)) ?? choices[0],
);
const currentSetting = (key: string, fallback: unknown) =>
account?.configuration?.[key] ?? fallback;
$effect(() => {
if (open && choices[0]) providerId = account?.providerId || initialProviderId || choices[0].id;
if (!open) error = "";
@@ -121,14 +124,14 @@
<label class="field" class:wide={accountSettings(selected).length === 1}>
<span>{field.label}</span>
{#if field.type === "select"}
<SelectField name={field.key} label={field.label} value={String(settingDefault(field))} options={field.options ?? []} required={field.required} />
<SelectField name={field.key} label={field.label} value={String(currentSetting(field.key, settingDefault(field)))} options={field.options ?? []} required={field.required} />
{:else if field.type === "toggle"}
<input name={field.key} type="checkbox" checked={settingDefault(field) === true} />
<input name={field.key} type="checkbox" checked={currentSetting(field.key, settingDefault(field)) === true} />
{:else}
<input
name={field.key}
type={field.sensitive ? "password" : field.type === "number" ? "number" : field.type === "url" ? "url" : "text"}
value={String(settingDefault(field))}
value={field.sensitive ? "" : String(currentSetting(field.key, settingDefault(field)))}
required={field.required}
autocomplete={field.key === "username" ? "username" : field.key === "password" ? "current-password" : "off"}
/>
+42 -3
View File
@@ -13,6 +13,7 @@
type ProviderAccount,
type ProviderDefinition,
type ProviderHealth,
type ProviderSetting,
type ProviderSummary,
type UiSchema,
} from "$lib/api";
@@ -35,6 +36,7 @@
sourceOriginLabel,
sourceStatus,
sourceTimingLabel,
settingDefault,
supportsStreamingDiagnostic,
} from "$lib/sources";
import { liveUpdates } from "$lib/live-updates.svelte";
@@ -215,6 +217,16 @@
return item.description || "Configure this Source and its accounts here.";
}
function accountSettingValue(account: ProviderAccount, field: ProviderSetting) {
if (field.sensitive)
return account.configuredFields?.includes(field.key) ? "Stored" : "Not set";
const saved = account.configuration?.[field.key];
const value = saved ?? settingDefault(field);
if (value === "" || value == null) return "Not set";
if (field.type === "toggle") return value === true || value === "true" ? "Enabled" : "Disabled";
return String(value);
}
async function saveSourceConfiguration(event: SubmitEvent) {
event.preventDefault();
if (!selectedSource || action) return;
@@ -536,6 +548,8 @@
<div><dt>Click to stream</dt><dd>{#if cts}<span class={`status-pill ${cts.health === "healthy" ? "healthy" : "degraded"}`}>{ctsMeasurementLabel(cts)}</span> · {relativeTime(cts.testedAt)}{:else if capabilities.some((item) => item.capability.toLowerCase() === "streaming")}Awaiting first sample{:else if capabilities.some((item) => item.capability.toLowerCase() === "download")}Download only{:else}Not applicable{/if}</dd></div>
</dl>
{:else if detailTab === "configuration" && detailKind === "source" && selectedSource}
{@const settings = accountSettings(selectedSource)}
{@const sourceAccounts = providerAccounts(selectedSource.id)}
<p class="source-configuration-copy">{sourcePurpose(selectedSource)}</p>
<div class="source-detail-actions">
{#if administrator && !sourceNeedsAccount(selectedSource) && selectedSource.categories?.some((item) => item.toLowerCase() === "streaming")}
@@ -544,12 +558,37 @@
{#if selectedSource.id === "apple-download"}
<button class="button-primary" type="button" onclick={() => { detailOpen = false; appleDownloadOpen = true; }}>Manage Apple Music GAMDL</button>
{/if}
{#if accountSettings(selectedSource).length}
<button class="button-primary" type="button" onclick={() => { connectProviderId = selectedSource!.id; detailOpen = false; connectOpen = true; }}>Connect account</button>
{:else if selectedSource.connectionKind !== "operator_managed"}
{#if !settings.length && selectedSource.connectionKind !== "operator_managed"}
<p>No account configuration is required for this extension capability.</p>
{/if}
</div>
{#if settings.length}
<div class="source-account-configurations">
{#each sourceAccounts as account}
<section class="source-account-configuration">
<header>
<span><strong>{account.sourceDisplayName || account.displayName}</strong><small>Encrypted account configuration</small></span>
<span class={`status-pill ${account.enabled ? "healthy" : "suggested"}`}>{account.enabled ? "Enabled" : "Disabled"}</span>
</header>
<dl class="source-detail-data">
{#each settings as field}
<div>
<dt>{field.label}</dt>
<dd>{accountSettingValue(account, field)}</dd>
{#if field.helpText}<small>{field.helpText}</small>{/if}
</div>
{/each}
</dl>
<footer><button class="button-primary" type="button" onclick={() => configure(account)}>Edit configuration</button></footer>
</section>
{:else}
<p class="credential-safety">These settings are saved on an encrypted Source account. Connect one to configure them.</p>
{/each}
</div>
<div class="source-detail-actions">
<button class="button-primary" type="button" disabled={!canManage} onclick={() => { connectProviderId = selectedSource!.id; detailOpen = false; connectOpen = true; }}>{sourceAccounts.length ? "Connect another account" : "Connect account"}</button>
</div>
{/if}
{#if selectedSource.connectionKind === "operator_managed" && selectedSource.configSchema?.length}
{#if administrator}
<form class="settings-fields source-configuration-form" onsubmit={(event) => void saveSourceConfiguration(event)}>
+19 -2
View File
@@ -20,7 +20,10 @@ const schema = {
{ id: "jellyfin", name: "Jellyfin", categories: ["streaming"] },
{
id: "lumen-audio", name: "Lumen Audio", categories: ["metadata", "streaming"],
accountSettings: [{ key: "token", label: "Access token", type: "password", sensitive: true, required: true }],
accountSettings: [
{ key: "token", label: "Access token", type: "password", sensitive: true, required: true },
{ key: "region", label: "Region", type: "select", options: ["us", "ca"], defaultValueJson: '"us"' },
],
},
{ id: "listenbrainz", name: "ListenBrainz", categories: ["scrobbling"] },
{
@@ -169,6 +172,7 @@ const responses: Record<string, unknown> = {
sourceDisplayName: "Lumen Audio", scope: "User", enabled: true, revision: 1,
ownerUserId: "user", ownerDisplayName: "Tester", createdByUserId: "user",
creatorDisplayName: "Tester",
configuration: { region: "ca" }, configuredFields: ["token", "region"],
secret: { configured: true, revoked: false }, createdAt: "2026-01-01", updatedAt: "2026-01-01",
}],
},
@@ -767,7 +771,7 @@ for (const viewport of viewports) {
await page.goto("#/settings/accounts");
await expect(page.getByRole("heading", { name: "Sources", level: 1 })).toBeVisible();
await page.goto("#/sources?source=lumen-audio&section=configuration");
await page.getByRole("button", { name: "Connect account" }).click();
await page.getByRole("button", { name: "Connect another account" }).click();
const sourceDialog = page.getByRole("dialog", { name: "Connect a Source" });
await expect(sourceDialog.getByRole("button", { name: "Source", exact: true })).toContainText("Lumen Audio");
await expect(sourceDialog.getByLabel("Access token")).toHaveValue("");
@@ -2100,6 +2104,19 @@ test("Sources keep primary actions visible and report scoped degradation", async
cell: Number.parseFloat(getComputedStyle(panel.querySelector(".sources-table td")!).paddingLeft),
}));
expect(tableGutters.cell).toBe(tableGutters.heading);
await lumenSource.getByRole("button").first().click();
const sourceDetails = page.getByRole("dialog", { name: "Lumen Audio", description: "Source capability and readiness" });
await sourceDetails.getByRole("tab", { name: "Configuration" }).click();
await expect(sourceDetails.getByText("Access token")).toBeVisible();
await expect(sourceDetails.getByText("Stored", { exact: true })).toBeVisible();
await expect(sourceDetails.getByText("Region")).toBeVisible();
await expect(sourceDetails.getByText("ca", { exact: true })).toBeVisible();
await expect(sourceDetails.getByRole("button", { name: "Connect another account" })).toBeVisible();
await sourceDetails.getByRole("button", { name: "Edit configuration" }).click();
const sourceEditor = page.getByRole("dialog", { name: "Configure Lumen Audio" });
await expect(sourceEditor.getByRole("button", { name: "Region" })).toHaveText("ca");
await expect(sourceEditor.getByLabel("Access token")).toHaveValue("");
await page.keyboard.press("Escape");
const disabledSource = page.locator(".sources-table tr").filter({ hasText: "Disabled Source" });
const disabledStatus = disabledSource.locator(".operational-mobile-state");
await expect(disabledStatus).toHaveText("Disabled");