feat: activity detection (#2055)

* feat: activity detection

* address comments
This commit is contained in:
Gabe Ruttner
2025-07-31 07:54:15 -07:00
committed by GitHub
parent 2df1cd7dc4
commit 0ddd6a2852
5 changed files with 201 additions and 3 deletions
@@ -100,6 +100,11 @@ export interface APICloudMetadata {
* @example true
*/
requireBillingForManagedCompute?: boolean;
/**
* the inactivity timeout to log out for user sessions in milliseconds
* @example 3600000
*/
inactivityLogoutMs?: number;
}
export interface APIErrors {
@@ -494,6 +499,9 @@ export interface LogLine {
timestamp: string;
instance: string;
line: string;
metadata?: object;
retryCount?: number;
level?: string;
}
export interface LogLineList {
@@ -53,6 +53,7 @@ export interface ApiConfig<SecurityDataType = unknown>
export enum ContentType {
Json = "application/json",
JsonApi = "application/vnd.api+json",
FormData = "multipart/form-data",
UrlEncoded = "application/x-www-form-urlencoded",
Text = "text/plain",
@@ -0,0 +1,86 @@
import { useCallback, useEffect, useRef } from 'react';
interface UseInactivityDetectionOptions {
timeoutMs?: number;
throttleMs?: number;
events?: string[];
onInactive?: () => void;
}
export function useInactivityDetection(
options: UseInactivityDetectionOptions = {},
) {
const {
timeoutMs = -1, // -1 means disabled
throttleMs = 1000, // 1 second throttle
events = [
'mousedown',
'mousemove',
'keypress',
'scroll',
'touchstart',
'click',
],
onInactive = () => {},
} = options;
const enabled = timeoutMs > 0;
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const throttleRef = useRef<NodeJS.Timeout | null>(null);
const resetTimeout = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
onInactive();
}, timeoutMs);
}, [onInactive, timeoutMs]);
const throttledResetTimeout = useCallback(() => {
if (throttleRef.current) {
return; // Already throttled
}
throttleRef.current = setTimeout(() => {
throttleRef.current = null;
}, throttleMs);
resetTimeout();
}, [throttleMs, resetTimeout]);
useEffect(() => {
if (!enabled) {
return;
}
resetTimeout();
events.forEach((event) => {
document.addEventListener(event, throttledResetTimeout, true);
});
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (throttleRef.current) {
clearTimeout(throttleRef.current);
}
events.forEach((event) => {
document.removeEventListener(event, throttledResetTimeout, true);
});
};
}, [
enabled,
timeoutMs,
throttleMs,
resetTimeout,
events,
throttledResetTimeout,
]);
return {};
}
+31 -2
View File
@@ -1,20 +1,49 @@
import MainNav from '@/components/molecules/nav-bar/nav-bar';
import { Outlet } from 'react-router-dom';
import { Outlet, useNavigate } from 'react-router-dom';
import api, { queries, TenantVersion, User } from '@/lib/api';
import { Loading } from '@/components/ui/loading.tsx';
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery } from '@tanstack/react-query';
import SupportChat from '@/components/molecules/support-chat';
import AnalyticsProvider from '@/components/molecules/analytics-provider';
import { useState, useEffect } from 'react';
import { useContextFromParent } from '@/lib/outlet';
import { useTenant } from '@/lib/atoms';
import { AxiosError } from 'axios';
import { useInactivityDetection } from '@/pages/auth/hooks/use-inactivity-detection';
import { cloudApi } from '@/lib/api/api';
export default function Authenticated() {
const [hasHasBanner, setHasBanner] = useState(false);
const { tenant } = useTenant();
const { data: cloudMetadata } = useQuery({
queryKey: ['metadata'],
queryFn: async () => {
const res = await cloudApi.metadataGet();
return res.data;
},
});
const navigate = useNavigate();
const logoutMutation = useMutation({
mutationKey: ['user:update:logout'],
mutationFn: async () => {
await api.userUpdateLogout();
},
onSuccess: () => {
navigate('/auth/login');
},
});
useInactivityDetection({
timeoutMs: cloudMetadata?.inactivityLogoutMs || -1,
onInactive: () => {
logoutMutation.mutate();
},
});
const userQuery = useQuery({
queryKey: ['user:get:current'],
retry: false,
@@ -2,7 +2,7 @@ import { Button } from '@/components/v1/ui/button';
import { Separator } from '@/components/v1/ui/separator';
import { useState } from 'react';
import { useApiError } from '@/lib/hooks';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import api, { queries, TenantVersion, UpdateTenantRequest } from '@/lib/api';
import { Switch } from '@/components/v1/ui/switch';
import { Label } from '@radix-ui/react-label';
@@ -18,6 +18,7 @@ import {
} from '@/components/v1/ui/dialog';
import { Alert, AlertDescription, AlertTitle } from '@/components/v1/ui/alert';
import { useCurrentTenantId, useTenantDetails } from '@/hooks/use-tenant';
import { cloudApi } from '@/lib/api/api';
export default function TenantSettings() {
const { tenant } = useTenantDetails();
@@ -33,6 +34,8 @@ export default function TenantSettings() {
<Separator className="my-4" />
<AnalyticsOptOut />
<Separator className="my-4" />
<InactivityTimeout />
<Separator className="my-4" />
<TenantVersionSwitcher />
</div>
</div>
@@ -239,3 +242,74 @@ const AnalyticsOptOut: React.FC = () => {
</>
);
};
const InactivityTimeout: React.FC = () => {
const { data: cloudMetadata } = useQuery({
queryKey: ['metadata'],
queryFn: async () => {
const res = await cloudApi.metadataGet();
return res.data;
},
});
const formatTimeoutMs = (timeoutMs: number | undefined) => {
if (!timeoutMs || timeoutMs <= 0) {
return 'Disabled';
}
const minutes = Math.floor(timeoutMs / 60000);
if (minutes < 60) {
return `${minutes} minute${minutes !== 1 ? 's' : ''}`;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
if (remainingMinutes === 0) {
return `${hours} hour${hours !== 1 ? 's' : ''}`;
}
return `${hours} hour${hours !== 1 ? 's' : ''} ${remainingMinutes} minute${remainingMinutes !== 1 ? 's' : ''}`;
};
const isDisabled =
!cloudMetadata?.inactivityLogoutMs || cloudMetadata.inactivityLogoutMs <= 0;
return (
<>
<h2 className="text-xl font-semibold leading-tight text-foreground">
Inactivity Timeout
</h2>
<Separator className="my-4" />
{isDisabled ? (
<>
<p className="text-gray-700 dark:text-gray-300 my-4">
Inactivity timeout is currently <strong>disabled</strong>. This
feature automatically logs out users after a period of inactivity to
enhance security.
</p>
<Alert>
<AlertDescription>
To enable inactivity timeout for your tenant, please contact
support.
</AlertDescription>
</Alert>
</>
) : (
<>
<p className="text-gray-700 dark:text-gray-300 my-4">
Current inactivity logout timeout:{' '}
<strong>
{formatTimeoutMs(cloudMetadata?.inactivityLogoutMs)}
</strong>
</p>
<Alert>
<AlertDescription>
Please contact support to change this configuration.
</AlertDescription>
</Alert>
</>
)}
</>
);
};