Просмотр исходного кода

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
Marian 1 месяц назад
Родитель
Сommit
05aebac67b

+ 51 - 0
frontend/src/__tests__/components/OIDCProviderSettings.test.tsx

@@ -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 - 0
frontend/src/api/client.ts

@@ -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 {

+ 34 - 16
frontend/src/components/OIDCProviderSettings.tsx

@@ -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>