feat(FN-1695): polish Settings Authentication Panel UX

- Reorder authentication providers: authenticated shown first, then alphabetically sorted
- Upgrade auth section to grouped/card-style hierarchy with section labels
- Update auth status badges to show '✓ Active' and '✗ Not connected'
- Add 'Authenticated' and 'Available' group labels in auth section
- Improve visual prominence of authenticated providers with subtle green accent
- Add section hint when no providers are authenticated
- Update CSS for new card-based layout with mobile-responsive styles
- Add tests for provider ordering behavior
This commit is contained in:
gsxdsm
2026-04-13 20:35:18 -07:00
parent c430f95c1d
commit 839e8a1b55
3 changed files with 338 additions and 95 deletions

View File

@@ -2088,6 +2088,16 @@ export function SettingsModal({
</>
);
case "authentication":
// Sort providers: authenticated first, then unauthenticated. Within each bucket, sort alphabetically by name.
const sortedProviders = [...authProviders].sort((a, b) => {
if (a.authenticated !== b.authenticated) {
return a.authenticated ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
const authenticatedProviders = sortedProviders.filter(p => p.authenticated);
const unauthenticatedProviders = sortedProviders.filter(p => !p.authenticated);
return (
<>
<h4 className="settings-section-heading">Authentication</h4>
@@ -2099,83 +2109,145 @@ export function SettingsModal({
</div>
) : (
<>
{!authProviders.some(p => p.authenticated) && (
<div className="settings-empty-state settings-muted">
Sign in to at least one provider to get started.
{authenticatedProviders.length === 0 && (
<div className="auth-section-hint">
Sign in to at least one provider to get started with AI models.
</div>
)}
{authProviders.map((provider) => (
<div key={provider.id} className="auth-provider-row">
<div className="auth-provider-info">
<strong>{provider.name}</strong>
<span
data-testid={`auth-status-${provider.id}`}
className={`auth-status-badge ${provider.authenticated ? "authenticated" : "not-authenticated"}`}
>
{provider.authenticated ? "✓ Authenticated" : "✗ Not authenticated"}
</span>
</div>
{provider.type === "api_key" ? (
<div className="auth-apikey-section">
<div className="auth-apikey-input-row">
<input
type="password"
className="auth-apikey-input"
placeholder="Enter API key"
value={apiKeyInputs[provider.id] ?? ""}
onChange={(e) => setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))}
disabled={authActionInProgress === provider.id}
/>
{provider.authenticated && !apiKeyInputs[provider.id] ? (
<button
className="btn btn-sm"
onClick={() => handleClearApiKey(provider.id)}
disabled={authActionInProgress === provider.id}
{authenticatedProviders.length > 0 && (
<div className="auth-provider-group">
<div className="auth-group-label">Authenticated</div>
{authenticatedProviders.map((provider) => (
<div key={provider.id} className="auth-provider-card auth-provider-card--authenticated">
<div className="auth-provider-header">
<div className="auth-provider-info">
<strong>{provider.name}</strong>
<span
data-testid={`auth-status-${provider.id}`}
className={`auth-status-badge ${provider.authenticated ? "authenticated" : "not-authenticated"}`}
>
Clear
</button>
Active
</span>
</div>
{provider.type === "api_key" ? (
<div className="auth-apikey-section">
<div className="auth-apikey-input-row">
<input
type="password"
className="auth-apikey-input"
placeholder="Enter API key"
value={apiKeyInputs[provider.id] ?? ""}
onChange={(e) => setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))}
disabled={authActionInProgress === provider.id}
/>
{provider.authenticated && !apiKeyInputs[provider.id] ? (
<button
className="btn btn-sm"
onClick={() => handleClearApiKey(provider.id)}
disabled={authActionInProgress === provider.id}
>
Clear
</button>
) : (
<button
className="btn btn-primary btn-sm"
onClick={() => handleSaveApiKey(provider.id)}
disabled={authActionInProgress === provider.id}
>
Save
</button>
)}
</div>
{authActionInProgress === provider.id && (
<small className="auth-apikey-progress">Saving</small>
)}
{apiKeyErrors[provider.id] && (
<small className="auth-apikey-error">{apiKeyErrors[provider.id]}</small>
)}
</div>
) : (
<button
className="btn btn-primary btn-sm"
onClick={() => handleSaveApiKey(provider.id)}
disabled={authActionInProgress === provider.id}
>
Save
</button>
<div>
{authActionInProgress === provider.id ? (
<button className="btn btn-sm" disabled>
Logging out
</button>
) : (
<button
className="btn btn-sm"
onClick={() => handleLogout(provider.id)}
>
Logout
</button>
)}
</div>
)}
</div>
{authActionInProgress === provider.id && (
<small className="auth-apikey-progress">Saving</small>
)}
{apiKeyErrors[provider.id] && (
<small className="auth-apikey-error">{apiKeyErrors[provider.id]}</small>
)}
</div>
) : (
<div>
{authActionInProgress === provider.id ? (
<button className="btn btn-sm" disabled>
{provider.authenticated ? "Logging out…" : "Waiting for login…"}
</button>
) : provider.authenticated ? (
<button
className="btn btn-sm"
onClick={() => handleLogout(provider.id)}
>
Logout
</button>
) : (
<button
className="btn btn-primary btn-sm"
onClick={() => handleLogin(provider.id)}
>
Login
</button>
)}
</div>
)}
))}
</div>
))}
)}
{unauthenticatedProviders.length > 0 && (
<div className="auth-provider-group">
<div className="auth-group-label">Available</div>
{unauthenticatedProviders.map((provider) => (
<div key={provider.id} className="auth-provider-card">
<div className="auth-provider-header">
<div className="auth-provider-info">
<strong>{provider.name}</strong>
<span
data-testid={`auth-status-${provider.id}`}
className={`auth-status-badge ${provider.authenticated ? "authenticated" : "not-authenticated"}`}
>
Not connected
</span>
</div>
{provider.type === "api_key" ? (
<div className="auth-apikey-section">
<div className="auth-apikey-input-row">
<input
type="password"
className="auth-apikey-input"
placeholder="Enter API key"
value={apiKeyInputs[provider.id] ?? ""}
onChange={(e) => setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))}
disabled={authActionInProgress === provider.id}
/>
<button
className="btn btn-primary btn-sm"
onClick={() => handleSaveApiKey(provider.id)}
disabled={authActionInProgress === provider.id}
>
Save
</button>
</div>
{authActionInProgress === provider.id && (
<small className="auth-apikey-progress">Saving</small>
)}
{apiKeyErrors[provider.id] && (
<small className="auth-apikey-error">{apiKeyErrors[provider.id]}</small>
)}
</div>
) : (
<div>
{authActionInProgress === provider.id ? (
<button className="btn btn-sm" disabled>
Waiting for login
</button>
) : (
<button
className="btn btn-primary btn-sm"
onClick={() => handleLogin(provider.id)}
>
Login
</button>
)}
</div>
)}
</div>
</div>
))}
</div>
)}
</>
)}
<small className="auth-hint">

View File

@@ -1096,7 +1096,7 @@ describe("SettingsModal", () => {
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
expect(screen.getByText("Anthropic")).toBeTruthy();
expect(screen.getByText("✗ Not authenticated")).toBeTruthy();
expect(screen.getByText("✗ Not connected")).toBeTruthy();
});
it("shows authenticated status with checkmark", async () => {
@@ -1110,7 +1110,7 @@ describe("SettingsModal", () => {
fireEvent.click(screen.getAllByText("Authentication")[0]);
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
expect(screen.getByText("✓ Authenticated")).toBeTruthy();
expect(screen.getByText("✓ Active")).toBeTruthy();
expect(screen.getByText("Logout")).toBeTruthy();
});
@@ -1174,14 +1174,92 @@ describe("SettingsModal", () => {
expect(unauthBadge.className).toContain("not-authenticated");
});
it("auth provider rows use auth-provider-row class", async () => {
it("renders authenticated providers before unauthenticated when API returns mixed order", async () => {
// API returns GitHub first (authenticated) and Anthropic second (not authenticated)
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
providers: [
{ id: "github", name: "GitHub", authenticated: true },
{ id: "anthropic", name: "Anthropic", authenticated: false },
],
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getAllByText("Authentication")[0]);
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
const providerRow = screen.getByText("Anthropic").closest(".auth-provider-row");
// GitHub (authenticated) should appear first
const container = document.body;
const cards = container.querySelectorAll(".auth-provider-card");
expect(cards.length).toBe(2);
// First card should be GitHub (authenticated) - check by looking for "GitHub" text in first card
const firstCardText = cards[0].textContent;
expect(firstCardText).toContain("GitHub");
expect(firstCardText).toContain("✓ Active");
// Second card should be Anthropic (unauthenticated)
const secondCardText = cards[1].textContent;
expect(secondCardText).toContain("Anthropic");
expect(secondCardText).toContain("✗ Not connected");
});
it("sorts providers alphabetically within each auth-state bucket", async () => {
// API returns in reverse alphabetical order
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
providers: [
{ id: "zod", name: "Zod", authenticated: false },
{ id: "anthropic", name: "Anthropic", authenticated: false },
{ id: "azure", name: "Azure", authenticated: false },
],
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getAllByText("Authentication")[0]);
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
const cards = document.body.querySelectorAll(".auth-provider-card");
expect(cards.length).toBe(3);
// Should be sorted alphabetically: Anthropic, Azure, Zod
const names = Array.from(cards).map(card => {
const strong = card.querySelector("strong");
return strong?.textContent;
});
expect(names).toEqual(["Anthropic", "Azure", "Zod"]);
});
it("renders Authenticated and Available group labels in auth section", async () => {
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true },
{ id: "github", name: "GitHub", authenticated: false },
],
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getAllByText("Authentication")[0]);
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
// Should show "Authenticated" group label (authenticated providers)
expect(screen.getAllByText("Authenticated").length).toBeGreaterThanOrEqual(1);
// Should show "Available" group label (unauthenticated providers)
expect(screen.getAllByText("Available").length).toBeGreaterThanOrEqual(1);
});
it("auth provider rows use auth-provider-card class", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getAllByText("Authentication")[0]);
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
const providerRow = screen.getByText("Anthropic").closest(".auth-provider-card");
expect(providerRow).toBeTruthy();
});
@@ -1326,7 +1404,7 @@ describe("SettingsModal", () => {
fireEvent.click(screen.getAllByText("Authentication")[0]);
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
expect(screen.getByText("Sign in to at least one provider to get started.")).toBeTruthy();
expect(screen.getByText("Sign in to at least one provider to get started with AI models.")).toBeTruthy();
// Provider rows should still be visible
expect(screen.getByText("Anthropic")).toBeTruthy();
expect(screen.getByText("GitHub")).toBeTruthy();
@@ -1346,7 +1424,7 @@ describe("SettingsModal", () => {
fireEvent.click(screen.getAllByText("Authentication")[0]);
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
expect(screen.queryByText("Sign in to at least one provider to get started.")).toBeNull();
expect(screen.queryByText("Sign in to at least one provider to get started with AI models.")).toBeNull();
// Provider rows should still be visible
expect(screen.getByText("Anthropic")).toBeTruthy();
expect(screen.getByText("GitHub")).toBeTruthy();
@@ -1687,7 +1765,7 @@ describe("SettingsModal", () => {
fireEvent.click(screen.getAllByText("Authentication")[0]);
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
const rows = container.querySelectorAll(".auth-provider-row");
const rows = container.querySelectorAll(".auth-provider-card");
expect(rows.length).toBe(2);
for (const row of rows) {

View File

@@ -3401,56 +3401,97 @@ body {
}
/* === Auth Provider Cards === */
.auth-provider-row {
.auth-section-hint {
padding: 12px 16px;
margin-bottom: 12px;
background: var(--bg-tertiary);
border-radius: var(--radius);
font-size: 13px;
color: var(--text-muted);
border-left: 3px solid var(--text-muted);
}
.auth-provider-group {
margin-bottom: 16px;
}
.auth-group-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
margin-bottom: 8px;
padding: 0 4px;
}
.auth-provider-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
margin-bottom: 8px;
overflow: hidden;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.auth-provider-card:hover {
border-color: var(--text-dim);
}
.auth-provider-card--authenticated {
border-color: color-mix(in srgb, var(--color-success) 40%, var(--border));
background: color-mix(in srgb, var(--color-success) 5%, var(--surface));
}
.auth-provider-card--authenticated:hover {
border-color: color-mix(in srgb, var(--color-success) 60%, var(--border));
}
.auth-provider-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
margin: 0;
border-bottom: 1px solid var(--border);
transition: background var(--transition-fast);
}
.auth-provider-row:hover {
background: rgba(255, 255, 255, 0.02);
}
.auth-provider-row:last-of-type {
border-bottom: none;
padding: 12px 16px;
gap: 12px;
}
.auth-provider-info {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
flex: 1;
min-width: 0;
}
.auth-provider-info strong {
font-weight: 500;
color: var(--text);
}
.auth-status-badge {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 2px 8px;
padding: 3px 8px;
border-radius: var(--radius-pill);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.3px;
white-space: nowrap;
}
.auth-status-badge.authenticated {
background: rgba(63, 185, 80, 0.15);
background: color-mix(in srgb, var(--color-success) 20%, transparent);
color: var(--color-success);
}
.auth-status-badge.not-authenticated {
background: rgba(248, 81, 73, 0.15);
background: color-mix(in srgb, var(--color-error) 15%, transparent);
color: var(--color-error);
}
.auth-hint {
display: block;
padding: 10px 20px 0;
padding: 12px 4px 0;
font-size: 12px;
color: var(--text-muted);
border-top: 1px solid var(--border);
margin-top: 8px;
}
.auth-apikey-section {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-end;
flex-shrink: 0;
}
.auth-apikey-input-row {
display: flex;
@@ -3462,9 +3503,9 @@ body {
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
padding: 5px 10px;
padding: 6px 10px;
font-size: 13px;
width: 200px;
width: 180px;
font-family: monospace;
}
.auth-apikey-input:focus {
@@ -3476,12 +3517,12 @@ body {
opacity: 0.6;
}
.auth-apikey-progress {
font-size: 12px;
font-size: 11px;
color: var(--text-muted);
padding-right: 4px;
}
.auth-apikey-error {
font-size: 12px;
font-size: 11px;
color: var(--color-error);
padding-right: 4px;
}
@@ -6748,6 +6789,58 @@ body {
gap: var(--space-sm);
}
.auth-section-hint {
margin: 0 14px 12px;
padding: 10px 14px;
}
.auth-provider-group {
margin-bottom: 12px;
}
.auth-group-label {
padding: 0 14px;
}
.auth-provider-card {
margin: 0 14px 8px;
}
.auth-provider-header {
flex-wrap: wrap;
padding: 10px 14px;
gap: 10px;
}
.auth-provider-info {
width: 100%;
flex-basis: 100%;
}
.auth-apikey-section {
width: 100%;
flex-basis: 100%;
align-items: stretch;
}
.auth-apikey-input-row {
flex-wrap: wrap;
}
.auth-apikey-input {
flex: 1;
min-width: 120px;
width: auto;
}
.auth-apikey-input-row .btn {
flex-shrink: 0;
}
.auth-hint {
padding: 12px 14px 0;
}
.backup-list ul {
max-height: 220px;
overflow-y: auto;