Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ jobs:
working-directory: app
run: npm ci

- name: Lint (non-blocking)
- name: Lint
working-directory: app
run: npm run lint --silent || true
run: npm run lint --silent

# - name: Cache Playwright Browsers
# id: playwright-cache
Expand Down
1 change: 1 addition & 0 deletions app/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,6 @@ env.json
*.sw?

playwright-report/
test-results/
access_rules.conf
env/.env.local
16 changes: 10 additions & 6 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,13 +177,14 @@ async function rootLoader() {
account,
profiles,
};
} catch (error: any) {
} catch (error: unknown) {




if (error instanceof Response) throw error;
const status = error?.response?.status ?? error?.status ?? (error instanceof Error && (error as any).status);
const err = error as Record<string, unknown>;
const status = (err?.response as Record<string, unknown>)?.status ?? err?.status ?? (error instanceof Error && (error as Record<string, unknown>).status);
if (status === 401 || status === 404) {
// Dispatch a unified session expired event; AuthProvider subscriber performs cleanup + toast
if (typeof window !== 'undefined') {
Expand Down Expand Up @@ -229,13 +230,14 @@ async function profilesOnlyLoader() {
account: null,
profiles,
};
} catch (error: any) {
} catch (error: unknown) {




if (error instanceof Response) throw error;
const status = error?.response?.status ?? error?.status ?? (error instanceof Error && (error as any).status);
const err = error as Record<string, unknown>;
const status = (err?.response as Record<string, unknown>)?.status ?? err?.status ?? (error instanceof Error && (error as Record<string, unknown>).status);
if (status === 401 || status === 404) {
if (typeof window !== 'undefined') {
dispatch({ type: 'auth/sessionExpired' });
Expand Down Expand Up @@ -476,15 +478,16 @@ function LoginWrapper() {
const { isAuthenticated } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const from = (location.state as any)?.from?.pathname || "/home";
const from = (location.state as Record<string, unknown>)?.from as Record<string, unknown> | undefined;
const fromPath = from?.pathname as string || "/home";

React.useEffect(() => {
// Only redirect if user is authenticated AND they came from another protected page
// Don't redirect if they're already on the login page (let Login component handle its own navigation)
if (isAuthenticated && location.state?.from) {
navigate("/home", { replace: true });
}
}, [isAuthenticated, navigate, from, location.state]);
}, [isAuthenticated, navigate, fromPath, location.state]);

// Always render the Login component to avoid black screen issues
// The redirect will happen in useEffect when authentication state updates
Expand Down Expand Up @@ -578,4 +581,5 @@ function App() {


export default App;
// eslint-disable-next-line react-refresh/only-export-components
export { useAuth, AuthContext, RootIndexRedirect };
2 changes: 1 addition & 1 deletion app/src/__tests__/e2e/account/updatePassword.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test';

// Intercepts account patch + current get to assert JSON Patch sequence
test('password update sends test+replace operations', async ({ page }) => {
let patchPayload: any = null;
let patchPayload: { updates: { operation: string; path: string; value?: string }[] } | null = null;
// Pre-auth via localStorage before app scripts run
await page.addInitScript(() => { window.localStorage.setItem('AUTH_KEY', 'true'); });
await page.route('**/api/v1/accounts', async route => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const dnsCheckRouteRegex = new RegExp(
'i'
);

async function mockDnsSequence(page: Page, responses: any[]) {
async function mockDnsSequence(page: Page, responses: Record<string, unknown>[]) {
let call = 0;
await page.route(dnsCheckRouteRegex, async (route: Route) => {
const r = responses[Math.min(call, responses.length - 1)];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { test, expect, type Page, type Route } from '@playwright/test';
import { registerMocks } from '../../mocks/registerMocks';

async function mockDnsSequence(page: Page, responses: any[]) {
async function mockDnsSequence(page: Page, responses: Record<string, unknown>[]) {
let call = 0;
await page.route(/https:\/\/.*\..*\/$/i, async (route: Route) => {
const r = responses[Math.min(call, responses.length - 1)];
call++;
if ((r as any).status === 404) {
return route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify((r as any).body) });
if (r.status === 404) {
return route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify(r.body) });
}
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(r) });
});
Expand Down
2 changes: 1 addition & 1 deletion app/src/__tests__/e2e/functional/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ test.describe('@functional Authentication', () => {
// Toast should appear (non-strict if animations delay)
try {
await expect(page.getByText('Logged out successfully.', { exact: false })).toBeVisible({ timeout: 4000 });
} catch (e) {
} catch {
// Soft warn if toast missing but proceed (remove this if toast becomes mandatory)
console.warn('[SOFT] Logout success toast not detected');
}
Expand Down
2 changes: 1 addition & 1 deletion app/src/__tests__/e2e/functional/login-advanced.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { AUTH_TOAST_IDS } from '../../../lib/authToasts';
import { installWebAuthnSuccessStub, installWebAuthnErrorStub } from '../utils/webauthn';

// Helper for ensuring password mode
async function ensurePasswordMode(page: any) {
async function ensurePasswordMode(page: import('@playwright/test').Page) {
if (await page.getByTestId('login-passkey-form').count()) {
await page.getByTestId('btn-login-toggle-mode').click();
}
Expand Down
2 changes: 1 addition & 1 deletion app/src/__tests__/e2e/functional/logout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ test.describe('Logout flows (desktop only)', () => {
await expect(page).toHaveURL(/\/home$/);

// Use global helper for deterministic logout since UI trigger test id not guaranteed
await page.evaluate(() => (window as any).__APP_DISPATCH_EVENT__({ type: 'auth/forceLogout' }));
await page.evaluate(() => (window as unknown as { __APP_DISPATCH_EVENT__: (e: { type: string }) => void }).__APP_DISPATCH_EVENT__({ type: 'auth/forceLogout' }));

await expect(page).toHaveURL(/\/login$/);
await expect(page.getByTestId(AUTH_TOAST_IDS.logoutSuccess)).toBeVisible();
Expand Down
4 changes: 2 additions & 2 deletions app/src/__tests__/e2e/functional/session-expiry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ test.describe('@functional Session Expiry', () => {
await expect.poll(() => page.url()).toMatch(/\/home$/);

// Wait until helper is attached (effect mounts after initial render)
await page.evaluate(() => (window as any).__APP_DISPATCH_EVENT__({ type: 'auth/forceLogout', reason: 'Session expired - please log in again.', toastType: 'error' }));
await page.evaluate(() => (window as unknown as { __APP_DISPATCH_EVENT__: (e: { type: string; reason: string; toastType: string }) => void }).__APP_DISPATCH_EVENT__({ type: 'auth/forceLogout', reason: 'Session expired - please log in again.', toastType: 'error' }));

await expect.poll(() => page.url(), { timeout: 8000 }).toMatch(/\/login$/);
await expect(page.getByTestId('login-page')).toBeVisible();
Expand All @@ -34,7 +34,7 @@ test.describe('@functional Session Expiry', () => {
await page.goto('/home');
await expect(page).toHaveURL(/\/home$/);
// Trigger forced logout
await page.evaluate(() => (window as any).__APP_DISPATCH_EVENT__({ type: 'auth/forceLogout', reason: 'Session expired - please log in again.', toastType: 'error' }));
await page.evaluate(() => (window as unknown as { __APP_DISPATCH_EVENT__: (e: { type: string; reason: string; toastType: string }) => void }).__APP_DISPATCH_EVENT__({ type: 'auth/forceLogout', reason: 'Session expired - please log in again.', toastType: 'error' }));
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByTestId('login-page')).toBeVisible();
await expect(page.getByTestId(AUTH_TOAST_IDS.sessionExpired)).toBeVisible();
Expand Down
2 changes: 1 addition & 1 deletion app/src/__tests__/e2e/functional/session-limit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { AUTH_TOAST_IDS } from '../../../lib/authToasts';

test.describe('Session limit dialog cancel flow (desktop only)', () => {
test('Cancel maintains login state without authenticating', async ({ page }) => {
let authed = false;
const authed = false;

// Mock login returning session limit on first attempt
await page.route(/\/api\/v1\/login(\/?|\?.*)$/i, async route => {
Expand Down
2 changes: 1 addition & 1 deletion app/src/__tests__/e2e/layout/blocklists-scroll.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const VIEWPORTS = [
];

// Utility: ensure enough mock blocklists to require vertical scrolling
function extendBlocklists(blocklists: any[], minCount = 30) {
function extendBlocklists(blocklists: Record<string, unknown>[], minCount = 30) {
const clone = [...blocklists];
let i = 0;
while (clone.length < minCount) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const PUBLIC_ROUTES = ['/login','/signup','/reset-password','/tos','/privacy'];
const PROTECTED_ROUTES = ['/home','/setup','/settings','/blocklists','/custom-rules','/account-preferences','/mobileconfig','/query-logs','/faq'];

// Interactions per route to surface latent overflow after dynamic UI changes.
async function performRouteInteractions(route: string, page: any) {
async function performRouteInteractions(route: string, page: import('@playwright/test').Page) {
// Global: if mobile nav button exists open & close nav
const menuBtn = page.getByRole('button', { name: /open navigation menu/i });
if (await menuBtn.count()) {
Expand Down Expand Up @@ -52,6 +52,7 @@ function isMobileProject(name?: string) {
}

test.describe('@layout mobile horizontal overflow ALL PAGES', () => {
// eslint-disable-next-line no-empty-pattern
test.beforeEach(async ({}, testInfo) => {
if (!isMobileProject(testInfo.project.name)) test.skip();
});
Expand Down
1 change: 1 addition & 0 deletions app/src/__tests__/e2e/layout/mobile-nav-scroll.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ function isMobileLike(name?: string) {
}

test.describe('@layout mobile nav scrollability', () => {
// eslint-disable-next-line no-empty-pattern
test.beforeEach(async ({}, testInfo) => {
if (!isMobileLike(testInfo.project.name)) test.skip();
});
Expand Down
2 changes: 1 addition & 1 deletion app/src/__tests__/e2e/layout/protected-pages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ test.describe('@layout Protected pages basic smoke', () => {
for (const route of routes) {
test(`loads ${route} without redirect`, async ({ page }) => {
await page.goto(route);
await expect(page).toHaveURL(new RegExp(route.replace('/', '\/')));
await expect(page).toHaveURL(new RegExp(route.replace('/', '/')));
if (!skipHorizontalCheck.has(route)) {
const hasHorizontal = await page.evaluate(() => {
const doc = document.documentElement;
Expand Down
1 change: 1 addition & 0 deletions app/src/__tests__/e2e/layout/setup-guide-scroll.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { registerMocks } from '../../mocks/registerMocks';
// Verifies the setup guide overlay/panel is scrollable in mobile landscape.

test.describe('@layout setup guide scrollability', () => {
// eslint-disable-next-line no-empty-pattern
test.beforeEach(async ({}, testInfo) => {
// Only run on mobile-like projects (naming pattern from config)
if (!/(chromium-mobile|iphone15pro)/i.test(testInfo.project.name)) test.skip();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AUTH_KEY } from '@/lib/consts';
// Ensures app header remains visible and clickable when setup overlay is open in mobile landscape.

test.describe('@layout setup overlay header visibility', () => {
// eslint-disable-next-line no-empty-pattern
test.beforeEach(async ({}, testInfo) => {
if (!/(chromium-mobile|iphone15pro)/i.test(testInfo.project.name)) test.skip();
});
Expand Down
2 changes: 1 addition & 1 deletion app/src/__tests__/e2e/layout/ultrawide-layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ test.describe('@layout Ultrawide clamp alignment', () => {
const conn = document.querySelector('[data-testid="conn-header-root"]') as HTMLElement | null;
const connContainer = conn?.parentElement as HTMLElement | null;
const maxWidth = content ? getComputedStyle(content).maxWidth : '';
const result: Record<string, any> = { docWidth, maxWidth };
const result: Record<string, unknown> = { docWidth, maxWidth };
const entries: [string, HTMLElement | null][] = [
['content', content],
['conn', conn],
Expand Down
2 changes: 1 addition & 1 deletion app/src/__tests__/e2e/utils/layoutAssertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export async function expectNoHorizontalOverflow(page: Page) {

export async function expectVisibleAndInViewport(page: Page, role: string, name: RegExp | string) {
const strict = process.env.STRICT_MOBILE === '1';
let locator = page.getByRole(role as any, { name });
let locator = page.getByRole(role as Parameters<Page['getByRole']>[0], { name });
const count = await locator.count();
if (count === 0 && role === 'navigation') {
// fallback to header or first nav element manually
Expand Down
12 changes: 6 additions & 6 deletions app/src/__tests__/e2e/utils/webauthn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
// Provides deterministic credential responses without relying on real platform authenticators.

/** Install a success stub for navigator.credentials.get returning a minimal public-key assertion */
export async function installWebAuthnSuccessStub(page: any) {
export async function installWebAuthnSuccessStub(page: import('@playwright/test').Page) {
await page.addInitScript(() => {
const enc = new TextEncoder();
function buf(str: string) { return enc.encode(str).buffer; }
// @ts-ignore
// @ts-expect-error - mocking WebAuthn API
navigator.credentials = navigator.credentials || {};
// @ts-ignore
// @ts-expect-error - mocking WebAuthn API
navigator.credentials.get = async () => ({
id: 'cred1',
rawId: buf('rawId'),
Expand All @@ -24,11 +24,11 @@ export async function installWebAuthnSuccessStub(page: any) {
}

/** Install a failing stub causing navigator.credentials.get to throw */
export async function installWebAuthnErrorStub(page: any, message = 'Simulated passkey failure') {
export async function installWebAuthnErrorStub(page: import('@playwright/test').Page, message = 'Simulated passkey failure') {
await page.addInitScript((msg: string) => {
// @ts-ignore
// @ts-expect-error - mocking WebAuthn API
navigator.credentials = navigator.credentials || {};
// @ts-ignore
// @ts-expect-error - mocking WebAuthn API
navigator.credentials.get = async () => { throw new Error(msg); };
}, message);
}
20 changes: 10 additions & 10 deletions app/src/__tests__/mocks/apiMocks.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,26 @@
import type { ModelAccount, ModelProfile, ModelProfileSettings, ModelAdvanced, ModelLogsSettings, ModelPrivacy, ModelSecurity, ModelStatisticsSettings } from '@/api/client/api';

// Minimal concrete sub-objects to satisfy required nested structures
const mockAdvanced: ModelAdvanced = {
const mockAdvanced = {
// add required primitive properties if any appear later in schema
} as any;
const mockLogs: ModelLogsSettings = {
} as unknown as ModelAdvanced;
const mockLogs = {
enabled: false,
log_clients_ips: false,
log_domains: false,
retention: 0,
} as any;
} as unknown as ModelLogsSettings;
const mockPrivacy = (enabledBlocklists: string[] = []): ModelPrivacy => ({
default_rule: 'allow',
subdomains_rule: 'allow',
blocklists: enabledBlocklists
} as any);
const mockSecurity: ModelSecurity = {
} as unknown as ModelPrivacy);
const mockSecurity = {
// fill with minimal required fields if present
} as any;
const mockStatistics: ModelStatisticsSettings = {
} as unknown as ModelSecurity;
const mockStatistics = {
enabled: false
} as any;
} as unknown as ModelStatisticsSettings;

const baseProfileSettings = (profileId: string, enabledBlocklists: string[] = []): ModelProfileSettings => ({
advanced: mockAdvanced,
Expand Down Expand Up @@ -64,7 +64,7 @@ export function createMockProfiles(count = 1, overrides: Partial<ModelProfile> =
}

// Convenience: build a small set of blocklists objects for UI listing
export function createMockBlocklists(): any[] {
export function createMockBlocklists(): Record<string, unknown>[] {
const now = new Date().toISOString();
return [
{
Expand Down
5 changes: 2 additions & 3 deletions app/src/__tests__/mocks/registerMocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export interface RegisterMocksOptions {
authenticated?: boolean;
profilesCount?: number;
enableBlocklists?: string[];
customProfiles?: any; // allow tests to pass partial override objects
customProfiles?: Partial<ModelProfile>[]; // allow tests to pass partial override objects
accountOverride?: Partial<ReturnType<typeof createMockAccount>>;
extraRoutes?: (page: Page) => Promise<void> | void;
ensureActiveProfile?: boolean; // new option
Expand Down Expand Up @@ -46,7 +46,6 @@ export async function registerMocks(page: Page, opts: RegisterMocksOptions = {})
await page.route(/http?:\/\/[^\s]+\/api\/v1\/accounts\/logout(\/|\?|$)|\/api\/v1\/accounts\/logout(\/|\?|$)/i, (r: Route) => {
const method = r.request().method();
// Debug log for visibility during test runs (non-fatal)
// eslint-disable-next-line no-console
console.log('[MOCK] intercept logout', method, r.request().url());
if (method === 'OPTIONS') {
return r.fulfill({ status: 200, headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, OPTIONS' }, body: '' });
Expand Down Expand Up @@ -80,7 +79,7 @@ export async function registerMocks(page: Page, opts: RegisterMocksOptions = {})
try {
const parsed = JSON.parse(p as string);
// Zustand store exposed? If not, try window.__APP_STORE__ pattern guard
// @ts-ignore
// @ts-expect-error - accessing test instrumentation on window
const store = window.__APP_STORE__ || undefined;
if (store?.setActiveProfile) {
store.setActiveProfile(parsed[0] || null);
Expand Down
10 changes: 5 additions & 5 deletions app/src/__tests__/unit/DownloadQueryLogsButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,13 @@ Object.defineProperty(URL, 'createObjectURL', { value: createObjectURLMock });
// Intercept anchor click creation
let capturedAnchor: HTMLAnchorElement | null = null;
const originalAppendChild = document.body.appendChild.bind(document.body);
document.body.appendChild = ((el: any) => {
if (el.tagName === 'A') {
capturedAnchor = el as HTMLAnchorElement;
vi.spyOn(el, 'click').mockImplementation(() => { });
document.body.appendChild = (<T extends Node>(el: T): T => {
if ((el as unknown as HTMLElement).tagName === 'A') {
capturedAnchor = el as unknown as HTMLAnchorElement;
vi.spyOn(el as unknown as HTMLAnchorElement, 'click').mockImplementation(() => { });
}
return originalAppendChild(el);
}) as any;
}) as typeof document.body.appendChild;

describe('DownloadQueryLogsButton', () => {
it('calls API and creates a downloadable blob with expected filename', async () => {
Expand Down
Loading
Loading