Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed
- **Authentication settings: startup-config save no longer offers a downloadable `config.json` fallback when the backend refuses the save as invalid.** `SettingsView.saveSettings()` previously wrapped `apiService.saveStartupConfig` in a bare `catch {}` and treated every failure as a disk-persistence problem — offering the user a downloadable `config.json` containing the *server-rejected* values so they could save it manually. That bypasses the new backend admin-existence guard entirely: a user who tries to enable the login screen with no admin user gets the backend's 400, the FE catches it, and the FE offers a download of the same `AuthenticationRequired=true` config the server just refused. The catch now inspects the thrown error's `status`: 4xx responses are validation refusals and surface as a hard error toast (no download offered); 5xx and network failures fall through to the existing download fallback, which is the right escape hatch for "server wants to save but can't write to disk."
- **Authentication settings: enabling the login screen now refuses to persist when no admin user exists.** `ConfigurationService.SaveStartupConfigAsync` queries `IUserService.GetAdminUsersAsync` whenever the incoming save *transitions* `AuthenticationRequired` from disabled to enabled, and throws if the admin user list is empty. This closes the carveout left by the credential-visibility and admin-provisioning fixes below: the settings DTO clears blank fields before save, so a user who flips "Enable login screen" with empty (or username-only) admin credentials silently skipped provisioning entirely and still reached the startup-config write, locking themselves out of an admin-less instance (recoverable by editing `config/config.json` back to `"AuthenticationRequired": "false"`, but a confusing first-time-setup trap). The check is scoped to the transition: subsequent saves while auth is already on (API key regenerations, port changes, log-level tweaks) don't re-query the admin list, and the common "just updating other startup fields with auth off" path stays unaffected. The admin block in `SaveApplicationSettings` runs before the startup-config write in the same save flow, so the typical "supply credentials and enable login in the same save" sequence has the admin row in place by the time the check runs.
- **Authentication settings: admin provisioning failures no longer silently let the auth-required toggle proceed.** `ConfigurationService.SaveApplicationSettingsAsync` previously caught any exception from `CreateUserAsync` / `UpdatePasswordAsync`, logged it, and returned successfully — so when admin credentials were supplied but the user-service rejected them (password policy violation, repo I/O error, concurrent-write race), `SettingsView.saveSettings()` would still go on to persist `AuthenticationRequired=true` on its second request. The result was an instance that required login but had no working admin account — exactly the lockout shape the credential-visibility fix below was meant to prevent. The catch now re-throws the failure so the caller aborts before the auth-toggle write. The settings row itself is still saved before the admin block (non-admin changes like notification triggers and webhooks shouldn't disappear because admin provisioning failed), and the no-credentials path remains an unchanged silent skip.
- **Authentication settings: corrected misleading description on the "Enable login screen" toggle.** Previously said *"Changes here are local and will not modify server files — edit config/config.json on the host to persist"*, which was demonstrably wrong: `SettingsView` actually writes `authenticationRequired` back to the server's startup config on save. The description now accurately states the toggle persists, and prompts the user to set admin credentials in the same save.
- **Authentication settings: admin credential fields are always visible.** Previously the *Admin Account Management* row was gated by `v-if="authEnabledComputed"` in `AuthenticationSection.vue`, which meant the only way to surface the username/password inputs was to first toggle on the login screen. If a user enabled `AuthenticationRequired` via `config.json` on the host (e.g., for the very first time) and then opened settings, the toggle reflected the server state (on), but if they instead opened settings *with auth still off*, the fields were hidden — and once they ticked the toggle and saved, the login screen activated immediately on the next page load, locking them out before they could create a user. The fields now render unconditionally so credentials can be configured before or after enabling auth. Help text and the password placeholder were updated to reflect the create-or-update semantics (blank password = keep existing).

## [0.2.71] - 2026-04-17

### Added
Expand Down
17 changes: 17 additions & 0 deletions fe/src/__tests__/AuthenticationSection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,23 @@ describe('AuthenticationSection', () => {
vi.restoreAllMocks()
})

it('renders the admin credential inputs even when authEnabled is false', async () => {
// Regression guard: the admin form must be visible while auth is disabled
// so a user can configure credentials before turning the login screen on.
// Previously this was gated by `v-if="authEnabledComputed"`, which made
// first-time setup require enabling auth (and the login screen) before
// any UI affordance for credentials existed — a lockout.
const { default: AuthenticationSection } =
await import('@/components/settings/AuthenticationSection.vue')
const wrapper = mount(AuthenticationSection, {
props: { settings: { adminUsername: '', adminPassword: '' }, authEnabled: false },
global: { components: { PasswordInput } },
})

expect(wrapper.find('input[type="text"][placeholder="Admin username"]').exists()).toBe(true)
expect(wrapper.findComponent(PasswordInput).exists()).toBe(true)
})

it('emits update:authEnabled when checkbox toggled', async () => {
const { default: AuthenticationSection } =
await import('@/components/settings/AuthenticationSection.vue')
Expand Down
7 changes: 3 additions & 4 deletions fe/src/components/settings/AuthenticationSection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
<CheckboxCard
v-model="authEnabledComputed"
title="Enable login screen"
description="Toggle to enable the login screen. This setting reflects the server's AuthenticationRequired value from config.json. Changes here are local and will not modify server filesedit config/config.json on the host to persist."
description="Toggle the login screen on or off. This setting is persisted to the server's AuthenticationRequired value in config.json when you save. When enabled, the admin credentials below are required for sign-inset or update them in the same save."
/>
</div>

Expand All @@ -36,9 +36,8 @@
</div>

<FormRow
v-if="authEnabledComputed"
label="Admin Account Management"
help="To set or change the admin password, enter a new password and save settings. The username and password are configured in config/config.json."
help="Set or change the admin username and password used for sign-in. Available whether or not the login screen is currently enabled so you can configure credentials before turning auth on. Leave the password blank to keep the existing one; provide both fields to create or update the admin user when you save."
>
<div class="admin-credentials">
<input
Expand All @@ -51,7 +50,7 @@
<PasswordInput
:modelValue="settings.adminPassword"
@update:modelValue="(v) => updateField('adminPassword', v)"
placeholder="New admin password (to update)"
placeholder="Admin password (leave blank to keep existing)"
/>
</div>
</FormRow>
Expand Down
62 changes: 40 additions & 22 deletions fe/src/views/SettingsView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -860,31 +860,49 @@ const saveSettings = async () => {
startupConfig.value = newCfg
startupConfigSaved = true
toast.success('Startup config', 'Startup configuration saved (config.json)')
} catch {
// If server can't persist startup config (e.g., permission denied), offer a fallback download of the config JSON
toast.info(
'Startup config',
'Could not persist startup config to disk. Preparing downloadable startup config so you can save it manually.',
)
try {
const blob = new Blob([JSON.stringify(newCfg, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'config.json'
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
toast.info(
'Startup config',
'Download started. Save the file to the server config directory to persist the change.',
)
} catch {
} catch (err) {
// Distinguish a server *validation refusal* (e.g. attempting to enable
// the login screen when no admin user exists — backend returns 400
// with an actionable message) from a *disk-persistence failure* (e.g.
// permission denied writing config.json — backend wants to save but
// can't). For validation refusals we must NOT offer the download
// fallback: letting the user manually save a server-rejected config
// would defeat the backend guard entirely (see PR #623). For genuine
// persistence failures, the download fallback is still the right
// escape hatch so the operator can save the file by hand.
const status = (err as { status?: number } | null)?.status
const isValidationRefusal = typeof status === 'number' && status >= 400 && status < 500
if (isValidationRefusal) {
const message =
err instanceof Error && err.message
? err.message
: 'Startup configuration refused by the server.'
toast.error('Startup config refused', message)
} else {
toast.info(
'Startup config',
'Also failed to prepare a download. Edit config/config.json on the host to make the change persistent.',
'Could not persist startup config to disk. Preparing downloadable startup config so you can save it manually.',
)
try {
const blob = new Blob([JSON.stringify(newCfg, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'config.json'
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
toast.info(
'Startup config',
'Download started. Save the file to the server config directory to persist the change.',
)
} catch {
toast.info(
'Startup config',
'Also failed to prepare a download. Edit config/config.json on the host to make the change persistent.',
)
}
}
}
// If authentication has just been enabled and persistence succeeded, ensure we
Expand Down
50 changes: 48 additions & 2 deletions listenarr.application/Common/ConfigurationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,17 @@
}
catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException)
{
logger.LogError(ex, "Failed to create or update admin user '{Username}' from application settings. Settings will still be saved.", settings.AdminUsername);
// Admin provisioning failed after credentials were supplied. Surface
// the failure to the caller — SettingsView relies on this throwing
// before it persists AuthenticationRequired=true on its second
// request, otherwise the user can be locked out of an instance
// that has no working admin (password-policy rejection, repo I/O
// error, race against a concurrent admin write, etc.). The
// settings row above was already saved, which is intentional:
// non-admin changes (notification triggers, webhooks, etc.) are
// worth preserving even when credential provisioning fails.
logger.LogError(ex, "Failed to create or update admin user '{Username}' from application settings; surfacing failure to caller", settings.AdminUsername);
throw;
}
}
catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException)
Expand Down Expand Up @@ -404,7 +414,43 @@
{
try
{
await startupConfigService.SaveAsync(config);
// Defense-in-depth backstop against the auth-enable lockout.
// SaveApplicationSettingsAsync's throw-on-failure (above) only
// covers the case where admin credentials were *supplied* but
// provisioning failed. The settings DTO clears blank fields
// before save, so a user who flips the login-screen toggle
// with empty (or username-only) credentials silently skips
// provisioning entirely — and without this check would still
// reach the startup-config write below, locking themselves
// out of an instance that has no working admin to log in as.
//
// Only enforced on the *transition* from auth-disabled to
// auth-enabled. Once auth is already on, the admin must
// already exist (or no one could have toggled it on through
// this same check), and every subsequent unrelated save
// — API key regenerations, port changes, log-level tweaks —
// shouldn't have to re-prove the admin row is still there.
// Demotion or deletion of the last admin row while auth is
// enabled is a separate concern and belongs in the user
// management path, not here.
if (config != null && config.IsAuthenticationEnabled())

Check failure

Code scanning / CodeQL

User-controlled bypass of sensitive method High

This condition guards a sensitive
action
, but a
user-provided value
controls it.
This condition guards a sensitive
action
, but a
user-provided value
controls it.

Check failure

Code scanning / CodeQL

User-controlled bypass of sensitive method High

This condition guards a sensitive
action
, but a
user-provided value
controls it.
Comment thread
therobbiedavis marked this conversation as resolved.
Dismissed
Comment thread
therobbiedavis marked this conversation as resolved.
Dismissed
{
var currentConfig = startupConfigService.GetConfig();
var wasAuthEnabled = currentConfig?.IsAuthenticationEnabled() == true;
if (!wasAuthEnabled)
{
var admins = await userService.GetAdminUsersAsync();
if (admins == null || admins.Count == 0)
{
throw new InvalidOperationException(
"Cannot enable the login screen: no admin user exists. " +
"Set an admin username and password in the same save to " +
"create one, or leave the login screen disabled.");
}
}
}

await startupConfigService.SaveAsync(config!);
}
catch (Exception ex) when (ex is not OperationCanceledException && ex is not OutOfMemoryException && ex is not StackOverflowException)
{
Expand Down
Loading
Loading