mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 17:34:23 +00:00
fix(auth): keep active sessions alive and add stay-signed-in (#1711)
This commit is contained in:
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 =
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user