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:
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user