fix(auth): keep active sessions alive and add stay-signed-in (#1711)

This commit is contained in:
Anso
2026-07-28 07:36:50 -04:00
committed by GitHub
parent 2d88d9f8a8
commit 681ecc7047
20 changed files with 775 additions and 95 deletions
+56
View File
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Login } from './Login';
const loginMock = vi.fn().mockResolvedValue({ success: true });
const ssoLdapLoginMock = vi.fn().mockResolvedValue({ success: true });
vi.mock('@/context/AuthContext', () => ({
useAuth: () => ({ login: loginMock, ssoLdapLogin: ssoLdapLoginMock }),
}));
beforeEach(() => {
loginMock.mockClear();
ssoLdapLoginMock.mockClear();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => [] }));
});
async function fillCredentials() {
await userEvent.type(screen.getByLabelText('Username'), 'admin');
await userEvent.type(screen.getByLabelText('Password'), 'password123');
}
describe('Login "Stay signed in"', () => {
it('submits remember=false by default', async () => {
render(<Login />);
await fillCredentials();
await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => expect(loginMock).toHaveBeenCalledWith('admin', 'password123', false));
});
it('submits remember=true when the checkbox is checked', async () => {
render(<Login />);
await fillCredentials();
await userEvent.click(screen.getByLabelText('Stay signed in'));
await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => expect(loginMock).toHaveBeenCalledWith('admin', 'password123', true));
});
it('threads remember=true through the LDAP form too', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => [{ provider: 'ldap', displayName: 'Directory', type: 'ldap' }],
}));
render(<Login />);
await waitFor(() => expect(screen.getByText('LDAP')).toBeInTheDocument());
await userEvent.click(screen.getByText('LDAP'));
await fillCredentials();
await userEvent.click(screen.getByLabelText('Stay signed in'));
await userEvent.click(screen.getByRole('button', { name: /sign in with ldap/i }));
await waitFor(() => expect(ssoLdapLoginMock).toHaveBeenCalledWith('admin', 'password123', true));
});
});
+18 -2
View File
@@ -3,6 +3,7 @@ import { useAuth } from '@/context/AuthContext';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { ArrowRight, KeyRound, Loader2 } from 'lucide-react';
import { AuthCanvas } from '@/components/auth/AuthCanvas';
import { AuthStepHeader } from '@/components/auth/AuthStepHeader';
@@ -63,6 +64,7 @@ export function Login({ className, ...props }: React.ComponentPropsWithoutRef<'d
const [loginMode, setLoginMode] = useState<'local' | 'ldap'>('local');
const [ssoProviders, setSsoProviders] = useState<SSOProvider[]>([]);
const [capsLock, setCapsLock] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
useEffect(() => {
fetch('/api/auth/sso/providers', { credentials: 'include' })
@@ -82,8 +84,8 @@ export function Login({ className, ...props }: React.ComponentPropsWithoutRef<'d
setIsLoading(true);
const result =
loginMode === 'ldap' && ssoLdapLogin
? await ssoLdapLogin(username, password)
: await login(username, password);
? await ssoLdapLogin(username, password, rememberMe)
: await login(username, password, rememberMe);
if (!result.success) setError(result.error || 'Login failed');
setIsLoading(false);
};
@@ -178,6 +180,20 @@ export function Login({ className, ...props }: React.ComponentPropsWithoutRef<'d
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="remember-me"
checked={rememberMe}
onCheckedChange={(c) => setRememberMe(c === true)}
/>
<label
htmlFor="remember-me"
className="text-sm text-stat-subtitle cursor-pointer select-none"
>
Stay signed in
</label>
</div>
{error && <ErrorRail>{error}</ErrorRail>}
<Button
+202 -67
View File
@@ -8,14 +8,18 @@ import { Combobox } from '@/components/ui/combobox';
import { ConfirmModal } from '@/components/ui/modal';
import { toast } from '@/components/ui/toast-store';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { TogglePill } from '@/components/ui/toggle-pill';
import { apiFetch } from '@/lib/api';
import { useAuth, type UserRole } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { CapabilityGate } from '@/components/CapabilityGate';
import { RefreshCw, Trash2, Plus, Pencil, ShieldOff } from 'lucide-react';
import { RefreshCw, Trash2, Plus, Pencil, ShieldOff, AlertTriangle } from 'lucide-react';
import { SettingsCallout } from './SettingsCallout';
import { SettingsPrimaryButton } from './SettingsActions';
import { SettingsSection } from './SettingsSection';
import { SettingsField } from './SettingsField';
import { SettingsActions, SettingsPrimaryButton } from './SettingsActions';
import { useMastheadStats } from './MastheadStatsContext';
import { DEFAULT_SETTINGS } from './types';
interface UserItem {
id: number;
@@ -35,6 +39,133 @@ interface RoleAssignmentItem {
created_at: number;
}
type SlidingRefresh = '0' | '1';
const DEFAULT_SLIDING_REFRESH: SlidingRefresh = DEFAULT_SETTINGS.session_sliding_refresh ?? '1';
function SessionPolicySkeleton() {
return (
<div className="space-y-3 rounded-lg border border-glass-border bg-glass p-4">
<Skeleton className="h-10 w-full" />
</div>
);
}
/**
* Instance-wide session behavior: whether an actively-used session silently
* renews itself instead of hard-expiring. Pinned to the local instance via
* `localOnly: true` on every fetch, like the rest of this page (a frontend
* convention, not a backend hub-only guard such as registries/secrets have),
* since it governs sign-in to this instance's own user table, not a remote
* node's.
*/
function SessionPolicySection() {
const { isAdmin } = useAuth();
const readOnly = !isAdmin;
const [phase, setPhase] = useState<'loading' | 'ready' | 'error'>('loading');
const [value, setValue] = useState<SlidingRefresh>(DEFAULT_SLIDING_REFRESH);
const [saved, setSaved] = useState<SlidingRefresh>(DEFAULT_SLIDING_REFRESH);
const [isSaving, setIsSaving] = useState(false);
const hasChanges = value !== saved;
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await apiFetch('/settings', { localOnly: true });
if (cancelled) return;
if (!res.ok) {
setPhase('error');
toast.error('Failed to load session policy.');
return;
}
const raw = (await res.json())?.session_sliding_refresh;
if (cancelled) return;
const loaded: SlidingRefresh = raw === '0' || raw === '1' ? raw : DEFAULT_SLIDING_REFRESH;
setValue(loaded);
setSaved(loaded);
setPhase('ready');
} catch {
if (!cancelled) {
setPhase('error');
toast.error('Failed to load session policy.');
}
}
})();
return () => { cancelled = true; };
}, []);
const saveSettings = async () => {
// Snapshot the submitted value: the toggle stays live while the PATCH is
// in flight, so adopting `value` after the await could mark an edit made
// meanwhile as already saved.
const submitted = value;
setIsSaving(true);
try {
const res = await apiFetch('/settings', {
method: 'PATCH',
localOnly: true,
body: JSON.stringify({ session_sliding_refresh: submitted }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || err?.message || 'Failed to save settings.');
return;
}
setSaved(submitted);
toast.success('Session policy saved.');
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setIsSaving(false);
}
};
if (phase === 'loading') return <SessionPolicySkeleton />;
if (phase === 'error') {
return (
<SettingsCallout
tone="error"
icon={<AlertTriangle className="h-4 w-4" />}
title="Could not load session policy"
subtitle="The current value could not be confirmed, so editing is unavailable. Reload the page to try again."
/>
);
}
return (
<fieldset disabled={readOnly} className="m-0 flex min-w-0 flex-col gap-6 border-0 p-0">
<SettingsSection title="Session policy">
<SettingsField
label="Keep active sessions alive"
helper="Silently renew a signed-in session while it stays active, instead of hard-expiring it on a fixed schedule. On by default; turn off to enforce a strict session ceiling regardless of activity."
>
<TogglePill
checked={value === '1'}
onChange={(next) => setValue(next ? '1' : '0')}
/>
</SettingsField>
</SettingsSection>
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? '1 unsaved' : undefined)}>
{!readOnly && (
<SettingsPrimaryButton size="sm" onClick={saveSettings} disabled={isSaving || !hasChanges}>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" strokeWidth={1.5} />
Saving
</>
) : (
'Save session policy'
)}
</SettingsPrimaryButton>
)}
</SettingsActions>
</fieldset>
);
}
export function UsersSection() {
const { user: currentUser } = useAuth();
const { isPaid } = useLicense();
@@ -261,7 +392,7 @@ export function UsersSection() {
return (
<CapabilityGate capability="users" featureName="User Management">
<div className="space-y-6">
<div className="flex flex-col gap-10">
{!showForm && (
<div className="flex justify-end">
<SettingsPrimaryButton size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
@@ -422,72 +553,76 @@ export function UsersSection() {
subtitle="Add an operator to give someone else access to this control plane."
/>
) : (
<div className="border border-glass-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/30 border-b border-glass-border">
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Username</th>
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Role</th>
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Created</th>
<th className="text-right px-4 py-2.5 font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => {
const isSelf = u.username === currentUser?.username;
return (
<tr key={u.id} className="border-b border-glass-border last:border-0 hover:bg-muted/10">
<td className="px-4 py-2.5 font-medium">
{u.username}
{isSelf && <span className="ml-2 text-xs text-muted-foreground">(you)</span>}
</td>
<td className="px-4 py-2.5">
<Badge variant={u.role === 'admin' ? 'default' : u.role === 'viewer' ? 'secondary' : 'outline'} className="text-xs capitalize">
{u.role}
</Badge>
</td>
<td className="px-4 py-2.5 text-muted-foreground">
{new Date(u.created_at).toLocaleDateString()}
</td>
<td className="px-4 py-2.5 text-right">
<div className="flex gap-1 justify-end">
<Button variant="ghost" size="sm" onClick={() => startEdit(u)}>
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
{u.mfaEnabled && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => setResetMfaTarget(u)}
>
<ShieldOff className="w-3.5 h-3.5 text-warning" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent>Reset 2FA</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<Button
variant="ghost"
size="sm"
disabled={isSelf}
onClick={() => setDeleteTarget(u)}
>
<Trash2 className="w-3.5 h-3.5 text-destructive" strokeWidth={1.5} />
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<SettingsSection title="Users" kicker={`${users.length} total`}>
<div className="mt-3 border border-glass-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/30 border-b border-glass-border">
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Username</th>
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Role</th>
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Created</th>
<th className="text-right px-4 py-2.5 font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => {
const isSelf = u.username === currentUser?.username;
return (
<tr key={u.id} className="border-b border-glass-border last:border-0 hover:bg-muted/10">
<td className="px-4 py-2.5 font-medium">
{u.username}
{isSelf && <span className="ml-2 text-xs text-muted-foreground">(you)</span>}
</td>
<td className="px-4 py-2.5">
<Badge variant={u.role === 'admin' ? 'default' : u.role === 'viewer' ? 'secondary' : 'outline'} className="text-xs capitalize">
{u.role}
</Badge>
</td>
<td className="px-4 py-2.5 text-muted-foreground">
{new Date(u.created_at).toLocaleDateString()}
</td>
<td className="px-4 py-2.5 text-right">
<div className="flex gap-1 justify-end">
<Button variant="ghost" size="sm" onClick={() => startEdit(u)}>
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
{u.mfaEnabled && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => setResetMfaTarget(u)}
>
<ShieldOff className="w-3.5 h-3.5 text-warning" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent>Reset 2FA</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<Button
variant="ghost"
size="sm"
disabled={isSelf}
onClick={() => setDeleteTarget(u)}
>
<Trash2 className="w-3.5 h-3.5 text-destructive" strokeWidth={1.5} />
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</SettingsSection>
)}
<SessionPolicySection />
<ConfirmModal
open={resetMfaTarget !== null}
onOpenChange={(open) => { if (!open) setResetMfaTarget(null); }}
@@ -0,0 +1,83 @@
/**
* Focused coverage for the "Keep active sessions alive" (session_sliding_refresh)
* toggle added to UsersSection. Does not re-test the pre-existing user CRUD
* table, which has no prior test file and is out of this change's scope.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
}));
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: true, user: { username: 'admin' } }) }));
vi.mock('@/context/LicenseContext', () => ({ useLicense: () => ({ isPaid: true }) }));
vi.mock('../MastheadStatsContext', () => ({ useMastheadStats: () => {} }));
vi.mock('@/components/CapabilityGate', () => ({ CapabilityGate: ({ children }: { children: React.ReactNode }) => children }));
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { UsersSection } from '../UsersSection';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
const mockedToastError = toast.error as unknown as ReturnType<typeof vi.fn>;
function mockApi(settingsOverrides: Record<string, string> = {}) {
mockedFetch.mockImplementation(async (path: string, opts?: { method?: string }) => {
if (path === '/users') return { ok: true, json: async () => [] };
if (path === '/settings' && (!opts?.method || opts.method === 'GET')) {
return { ok: true, json: async () => ({ session_sliding_refresh: '1', ...settingsOverrides }) };
}
if (path === '/settings' && opts?.method === 'PATCH') {
return { ok: true, json: async () => ({ success: true }) };
}
return { ok: true, json: async () => ({}) };
});
}
beforeEach(() => {
mockedFetch.mockReset();
mockApi();
});
describe('UsersSection > session policy', () => {
it('renders the toggle in the ON state from a fresh-install default payload', async () => {
render(<UsersSection />);
const toggle = await screen.findByRole('switch');
await waitFor(() => expect(toggle).toHaveAttribute('aria-checked', 'true'));
});
it('shows an error state and does not present the default value as real when the load fails', async () => {
mockedFetch.mockImplementation(async (path: string, opts?: { method?: string }) => {
if (path === '/users') return { ok: true, json: async () => [] };
if (path === '/settings' && (!opts?.method || opts.method === 'GET')) {
return { ok: false, status: 500, json: async () => ({ error: 'boom' }) };
}
return { ok: true, json: async () => ({}) };
});
render(<UsersSection />);
await screen.findByText(/could not load session policy/i);
expect(screen.queryByRole('switch')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /save session policy/i })).not.toBeInTheDocument();
expect(mockedToastError).toHaveBeenCalled();
});
it('renders OFF when the settings payload has it disabled, and PATCHes only that key on save', async () => {
mockApi({ session_sliding_refresh: '0' });
render(<UsersSection />);
const toggle = await screen.findByRole('switch');
await waitFor(() => expect(toggle).toHaveAttribute('aria-checked', 'false'));
await userEvent.click(toggle);
const save = await screen.findByRole('button', { name: /save session policy/i });
await userEvent.click(save);
await waitFor(() => {
const patchCall = [...mockedFetch.mock.calls].reverse().find((c) => c[1]?.method === 'PATCH');
expect(patchCall).toBeDefined();
expect(JSON.parse(patchCall![1].body as string)).toEqual({ session_sliding_refresh: '1' });
});
});
});
+1 -1
View File
@@ -82,7 +82,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
group: 'access',
label: 'Users',
description: 'Operators, role assignments, and access scopes.',
keywords: ['operators', 'team', 'rbac', 'roles', 'permissions'],
keywords: ['operators', 'team', 'rbac', 'roles', 'permissions', 'session', 'sliding refresh', 'stay signed in', 'sign out', 'logout'],
tier: null,
scope: 'global',
adminOnly: true,
@@ -23,6 +23,7 @@ export interface PatchableSettings {
auto_create_missing_external_networks?: '0' | '1';
image_update_sidebar_indicators?: '0' | '1';
notification_dispatch_retries?: string;
session_sliding_refresh?: '0' | '1';
}
export const DEFAULT_SETTINGS: PatchableSettings = {
@@ -50,6 +51,7 @@ export const DEFAULT_SETTINGS: PatchableSettings = {
auto_create_missing_external_networks: '0',
image_update_sidebar_indicators: '1',
notification_dispatch_retries: '0',
session_sliding_refresh: '1',
};
export type SectionId =
+6 -6
View File
@@ -34,8 +34,8 @@ interface AuthContextType {
permissionsStatus: PermissionsStatus;
permissionsReady: boolean;
can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean;
login: (username: string, password: string) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>;
ssoLdapLogin: (username: string, password: string) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>;
login: (username: string, password: string, remember?: boolean) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>;
ssoLdapLogin: (username: string, password: string, remember?: boolean) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>;
submitMfa: (code: string, opts?: { isBackupCode?: boolean }) => Promise<{ success: boolean; error?: string; retryAfter?: number }>;
cancelMfa: () => Promise<void>;
logout: () => Promise<void>;
@@ -143,7 +143,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return false;
}, [permissions]);
const login = async (username: string, password: string): Promise<{ success: boolean; error?: string; mfaRequired?: boolean }> => {
const login = async (username: string, password: string, remember = false): Promise<{ success: boolean; error?: string; mfaRequired?: boolean }> => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
@@ -151,7 +151,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ username, password }),
body: JSON.stringify({ username, password, remember }),
});
const data = await response.json();
@@ -172,13 +172,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
};
const ssoLdapLogin = async (username: string, password: string): Promise<{ success: boolean; error?: string; mfaRequired?: boolean }> => {
const ssoLdapLogin = async (username: string, password: string, remember = false): Promise<{ success: boolean; error?: string; mfaRequired?: boolean }> => {
try {
const response = await fetch('/api/auth/sso/ldap', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password }),
body: JSON.stringify({ username, password, remember }),
});
const data = await response.json();