feat(oidc): show the env-managed provider as locked in settings

The API answers 409 to any write against this provider, so offering edit,
delete and the enable toggle would promise a change that cannot land -- the
operator would click, see nothing happen, and have no way to tell why. The
controls are hidden and a lock badge names the reason instead.

Reuses settings.environmentManagedLabel, the string the Home Assistant
env-managed fields already use: same situation, same wording, and no new key
to keep in parity across eleven locales.

is_env_managed is optional on the client type so a response from an older
backend still type-checks.

Refs #2593
This commit is contained in:
Marian
2026-07-28 21:05:29 +00:00
parent 8d1b2b9027
commit 05aebac67b
3 changed files with 90 additions and 16 deletions
@@ -325,3 +325,54 @@ describe('OIDCProviderSettings', () => {
});
});
});
describe('env-managed provider (#2593)', () => {
const envManagedProvider = {
...mockProviders[0],
id: 2,
name: 'EnvIdP',
is_env_managed: true,
};
it('marks the provider as environment managed', async () => {
server.use(
http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([envManagedProvider]))
);
render(<OIDCProviderSettings />);
await waitFor(() => {
expect(screen.getByText('EnvIdP')).toBeInTheDocument();
});
expect(screen.getByText(/Environment Managed/i)).toBeInTheDocument();
});
it('offers no edit or delete control for it', async () => {
server.use(
http.get('/api/v1/auth/oidc/providers/all', () => HttpResponse.json([envManagedProvider]))
);
render(<OIDCProviderSettings />);
await waitFor(() => {
expect(screen.getByText('EnvIdP')).toBeInTheDocument();
});
// Startup rewrites this row from the environment on every boot, and the API
// answers 409 — offering the controls would promise an edit that cannot land.
expect(screen.queryByTestId('edit-provider-2')).not.toBeInTheDocument();
expect(screen.queryByTestId('delete-provider-2')).not.toBeInTheDocument();
});
it('still offers them for a UI-created provider', async () => {
server.use(
http.get('/api/v1/auth/oidc/providers/all', () =>
HttpResponse.json([{ ...mockProviders[0], is_env_managed: false }])
)
);
render(<OIDCProviderSettings />);
await waitFor(() => {
expect(screen.getByText('TestIdP')).toBeInTheDocument();
});
expect(screen.getByTestId('edit-provider-1')).toBeInTheDocument();
expect(screen.getByTestId('delete-provider-1')).toBeInTheDocument();
});
});
+5
View File
@@ -3518,6 +3518,11 @@ export interface OIDCProvider {
// #1589: when true, the LoginPage redirects unauthenticated visitors
// straight to this provider on mount. At most one provider may carry this.
is_autologin: boolean;
// #2593: defined by BAMBUDDY_OIDC_* and rewritten from the environment on
// every boot. The API answers 409 to any write, so the settings UI must not
// offer edit/delete controls that cannot succeed. Optional so a response
// from an older backend still type-checks.
is_env_managed?: boolean;
}
export interface OIDCProviderCreate {
@@ -1,6 +1,6 @@
import { useState, type ReactNode } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit2, Trash2, Globe, Check, X, RefreshCw, ExternalLink, ImageOff } from 'lucide-react';
import { Plus, Edit2, Trash2, Globe, Check, X, RefreshCw, ExternalLink, ImageOff, Lock } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { api } from '../api/client';
import type { Group, OIDCProvider, OIDCProviderCreate } from '../api/client';
@@ -386,6 +386,11 @@ export function OIDCProviderSettings() {
<X className="w-3 h-3" /> {t('common.disabled')}
</span>
)}
{provider.is_env_managed && (
<span className="flex items-center gap-1 text-xs text-bambu-green">
<Lock className="w-3 h-3" /> {t('settings.environmentManagedLabel')}
</span>
)}
</div>
<div className="flex items-center gap-1 text-bambu-gray text-xs mt-0.5">
<ExternalLink className="w-3 h-3" />
@@ -417,21 +422,34 @@ export function OIDCProviderSettings() {
<ImageOff className="w-4 h-4" />
</Button>
)}
<Toggle
checked={provider.is_enabled}
onChange={() => toggleEnabled(provider)}
disabled={updateMutation.isPending}
/>
<Button
variant="secondary"
size="sm"
onClick={() => setEditingId(editingId === provider.id ? null : provider.id)}
>
<Edit2 className="w-4 h-4" />
</Button>
<Button variant="danger" size="sm" onClick={() => setDeleteTarget(provider)}>
<Trash2 className="w-4 h-4" />
</Button>
{/* #2593: startup rewrites the env-managed row from BAMBUDDY_OIDC_*
and the API answers 409, so offering these would promise a
change that cannot land. */}
{!provider.is_env_managed && (
<>
<Toggle
checked={provider.is_enabled}
onChange={() => toggleEnabled(provider)}
disabled={updateMutation.isPending}
/>
<Button
variant="secondary"
size="sm"
onClick={() => setEditingId(editingId === provider.id ? null : provider.id)}
data-testid={`edit-provider-${provider.id}`}
>
<Edit2 className="w-4 h-4" />
</Button>
<Button
variant="danger"
size="sm"
onClick={() => setDeleteTarget(provider)}
data-testid={`delete-provider-${provider.id}`}
>
<Trash2 className="w-4 h-4" />
</Button>
</>
)}
</div>
</div>
</CardHeader>