mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-08-19 12:32:34 -04:00
fix(sources): show saved account configuration
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"}
|
||||
/>
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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§ion=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");
|
||||
|
||||
Reference in New Issue
Block a user