Skip to content
Open
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
3 changes: 3 additions & 0 deletions app/env/.env.production
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ VITE_SENTRY_RELEASE=
VITE_SENTRY_ENVIRONMENT=production
VITE_DNS_SERVER_LOCATIONS=ams1:Amsterdam,tor1:Toronto
VITE_RESYNC_URL=https://www.ivpn.net/en/account/

# Base URL for the IVPN marketing site — all IVPN links on the public landing page derive from this.
VITE_IVPN_HOME_URL=https://www.ivpn.net
3 changes: 3 additions & 0 deletions app/env/.env.staging
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ VITE_SENTRY_RELEASE=
VITE_SENTRY_ENVIRONMENT=staging
VITE_DNS_SERVER_LOCATIONS=vm1:Quebec
VITE_RESYNC_URL=https://staging.tamazaki.com/en/account/

# Base URL for the IVPN marketing site — all IVPN links on the public landing page derive from this.
VITE_IVPN_HOME_URL=https://staging.tamazaki.com
3 changes: 3 additions & 0 deletions app/env/.env.test
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@ VITE_SENTRY_RELEASE=
VITE_SENTRY_ENVIRONMENT=test
VITE_DNS_SERVER_LOCATIONS=ams1:Amsterdam,tor1:Toronto
VITE_RESYNC_URL=https://www.ivpn.net/en/account/

# Base URL for the IVPN marketing site — all IVPN links on the public landing page derive from this.
VITE_IVPN_HOME_URL=https://staging.tamazaki.com
13 changes: 11 additions & 2 deletions app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,16 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="modDNS" />
<meta name="format-detection" content="telephone=no" />
<meta name="description" content="modDNS - DNS management dashboard" />
<meta name="description" content="DNS filtering with configurable blocklists, custom rules, and encrypted query protocols. Open source, independently audited. Included in IVPN Plus and Pro Suite." />
<meta property="og:title" content="modDNS — Open-source DNS filtering by IVPN" />
<meta property="og:description" content="Configurable blocklists, custom rules, encrypted DNS protocols. Independently audited. No query logging by default." />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://moddns.net" />
<meta property="og:image" content="https://moddns.net/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="modDNS — Open-source DNS filtering by IVPN" />
<meta name="twitter:card" content="summary_large_image" />
<!-- Early theme bootstrap to prevent initial white flash. This sets data-shadcn-ui-mode + body bg before first paint.
CSP: This inline script is whitelisted by sha256 hash in nginx.conf.
If you modify it, update the hash there or the browser will block it. -->
Expand Down Expand Up @@ -51,7 +60,7 @@
/* Respect reduced motion for any future fade transitions */
@media (prefers-reduced-motion: reduce) { .fade-enter-active, .fade-exit-active { transition: none !important; } }
</style>
<title>modDNS</title>
<title>modDNS — Open-source DNS filtering by IVPN</title>
</head>
<body style="overflow-x:hidden; background-color: #111111;">
<div id="root" style="background-color: #111111; min-height: 100vh;"></div>
Expand Down
Binary file added app/public/fonts/IBMPlexMono-Regular.woff2
Binary file not shown.
Binary file added app/public/fonts/VT323-Regular.woff2
Binary file not shown.
Binary file added app/public/og-image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 13 additions & 3 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const AccountPreferences = lazyWithRetry(() => import('@/pages/account_preferenc
const MobileconfigPage = lazyWithRetry(() => import('@/pages/mobileconfig/MobileconfigPage'));
const MobileconfigDownload = lazyWithRetry(() => import('@/pages/mobileconfig/MobileconfigDownload'));
const HomeScreen = lazyWithRetry(() => import('./pages/home/HomeScreen'));
const Landing = lazyWithRetry(() => import('./pages/landing/Landing'));

import { createBrowserRouter, RouterProvider, Navigate, Outlet, useLoaderData, useLocation, useNavigate, redirect, ScrollRestoration } from 'react-router-dom';
import { ThemeProvider } from "@/components/theme-provider"
Expand Down Expand Up @@ -75,6 +76,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children

// Public route predicate (keep in sync with router public section)
const isPublicPath = (p: string) => (
p === '/' ||
p === '/login' ||
p === '/signup' ||
p === '/tos' ||
Expand Down Expand Up @@ -501,11 +503,19 @@ function ProtectedLayout() {
}

function RootIndexRedirect() {
// The public landing page is the canonical face of `/` for everyone —
// authenticated visitors see it too. The auth check below is read at the
// routing layer (with the localStorage belt-and-braces guard against stale
// React state vs. localStorage drift) and passed down so Landing can swap
// [01 LOGIN] for [01 DASHBOARD]. Landing itself stays a "dumb" component.
//
// The function name is kept for backwards-compat with the existing
// unit/e2e tests and the `export { RootIndexRedirect }` at the bottom of
// this file; it no longer actually redirects.
const { isAuthenticated } = useAuth();
const localAuthed = typeof window !== 'undefined' ? localStorage.getItem(AUTH_KEY) === 'true' : isAuthenticated;
const target = isAuthenticated && localAuthed ? '/home' : '/login';

return <Navigate to={target} replace />;
const authed = isAuthenticated && localAuthed;
return <Suspense fallback={<div />}><Landing isAuthenticated={authed} /></Suspense>;
}

function SetupWithLoader() {
Expand Down
38 changes: 33 additions & 5 deletions app/src/__tests__/e2e/functional/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,22 +86,50 @@ test.describe('@functional Authentication', () => {
});

test.describe('@functional Root index redirects', () => {
test('unauthenticated visit to root redirects to /login', async ({ page }) => {
test('unauthenticated visit to root shows the landing page with [01 LOGIN]', async ({ page }) => {
await registerMocks(page, { authenticated: false });
await page.goto('/');
await expect.poll(async () => page.url()).toMatch(/\/login$/);
// Stay on / and render the landing chrome (CRT-themed marketing page).
await expect.poll(async () => page.url()).toMatch(/\/$/);
await expect(page.locator('.moddns-landing')).toBeVisible();
// Unauth nav surfaces the LOGIN entry point, not the dashboard shortcut.
await expect(page.getByRole('link', { name: '[01 LOGIN]' })).toBeVisible();
await expect(page.getByRole('link', { name: '[01 DASHBOARD]' })).toHaveCount(0);
});

test('stale auth flag with expired session still redirects to /login', async ({ page }) => {
test('stale auth flag at root still shows the landing page', async ({ page }) => {
// `/` is now unconditionally the landing page. Even a stale AUTH_KEY=true
// in localStorage no longer triggers a redirect away from /. The 401 path
// through /home → /login only kicks in when the user actively visits a
// protected route (covered by the protected-route test above).
await registerMocks(page, { authenticated: false });
await page.addInitScript((key: string) => { window.localStorage.setItem(key, 'true'); }, AUTH_KEY);
await page.goto('/');
await expect.poll(async () => page.url()).toMatch(/\/login$/);
await expect.poll(async () => page.url()).toMatch(/\/$/);
await expect(page.locator('.moddns-landing')).toBeVisible();
});

test('valid session at root immediately navigates to /home', async ({ page }) => {
test('valid session at root stays on the landing page with [01 DASHBOARD]', async ({ page }) => {
// Authenticated visitors see the marketing landing page too. The
// [01 LOGIN] CTA in the nav swaps to [01 DASHBOARD] linking straight to
// /home; [01 LOGIN] should not be shown to a logged-in user.
await registerMocks(page, { authenticated: true, customProfiles: [{ id: 'prof_1', name: 'Default' }] });
await page.addInitScript((key: string) => { window.localStorage.setItem(key, 'true'); }, AUTH_KEY);
await page.goto('/');
await expect.poll(async () => page.url()).toMatch(/\/$/);
await expect(page.locator('.moddns-landing')).toBeVisible();
const dashboardLink = page.getByRole('link', { name: '[01 DASHBOARD]' });
await expect(dashboardLink).toBeVisible();
await expect(dashboardLink).toHaveAttribute('href', '/home');
await expect(page.getByRole('link', { name: '[01 LOGIN]' })).toHaveCount(0);
});

test('authenticated visit to /login redirects to /home', async ({ page }) => {
// Counterpart to the change above: authed users who click LOGIN from the
// landing page (or otherwise land on /login) should still bounce to /home.
await registerMocks(page, { authenticated: true, customProfiles: [{ id: 'prof_1', name: 'Default' }] });
await page.addInitScript((key: string) => { window.localStorage.setItem(key, 'true'); }, AUTH_KEY);
await page.goto('/login');
await expect.poll(async () => page.url()).toMatch(/\/home$/);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { expectNoHorizontalOverflow } from '../utils/layoutAssertions';
// Runs only on explicitly mobile projects (chromium-mobile, iphone15pro) to keep suite lean.
// Covers both public and protected routes + key interactions that could introduce overflow.

const PUBLIC_ROUTES = ['/login','/signup','/reset-password','/tos','/privacy','/faq'];
const PUBLIC_ROUTES = ['/','/login','/signup','/reset-password','/tos','/privacy','/faq'];
const PROTECTED_ROUTES = ['/home','/setup','/settings','/blocklists','/custom-rules','/account-preferences','/mobileconfig','/query-logs'];

// Interactions per route to surface latent overflow after dynamic UI changes.
Expand Down
31 changes: 25 additions & 6 deletions app/src/__tests__/unit/RootIndexRedirect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ vi.mock('react-router-dom', async () => {
};
});

// Stub the lazy-loaded Landing component so it renders synchronously and
// surfaces its `isAuthenticated` prop in the DOM for assertion.
vi.mock('@/pages/landing/Landing', () => ({
default: ({ isAuthenticated }: { isAuthenticated?: boolean }) => (
<div data-testid="landing-page" data-authed={String(Boolean(isAuthenticated))} />
),
}));

describe('RootIndexRedirect', () => {
type AuthContextValue = React.ContextType<typeof AuthContext>;

Expand All @@ -35,27 +43,38 @@ describe('RootIndexRedirect', () => {
localStorage.clear();
});

it('navigates to /home when both auth state and local storage are true', () => {
it('renders the landing page with isAuthenticated=true when both auth state and local storage agree', async () => {
localStorage.setItem(AUTH_KEY, 'true');

renderWithAuth({ isAuthenticated: true });

expect(screen.getByTestId('navigate')).toHaveAttribute('data-to', '/home');
const landing = await screen.findByTestId('landing-page');
expect(landing).toBeInTheDocument();
expect(landing).toHaveAttribute('data-authed', 'true');
expect(screen.queryByTestId('navigate')).not.toBeInTheDocument();
});

it('navigates to /login when auth state is false', () => {
it('renders the landing page with isAuthenticated=false when auth state is false', async () => {
localStorage.setItem(AUTH_KEY, 'true');

renderWithAuth({ isAuthenticated: false });

expect(screen.getByTestId('navigate')).toHaveAttribute('data-to', '/login');
const landing = await screen.findByTestId('landing-page');
expect(landing).toBeInTheDocument();
expect(landing).toHaveAttribute('data-authed', 'false');
expect(screen.queryByTestId('navigate')).not.toBeInTheDocument();
});

it('falls back to /login when local storage flag is missing', () => {
it('renders the landing page with isAuthenticated=false when local storage flag is missing', async () => {
localStorage.removeItem(AUTH_KEY);

renderWithAuth({ isAuthenticated: true });

expect(screen.getByTestId('navigate')).toHaveAttribute('data-to', '/login');
const landing = await screen.findByTestId('landing-page');
expect(landing).toBeInTheDocument();
// Belt-and-braces guard: stale React state without localStorage backing
// does not flip the page into the authenticated UI.
expect(landing).toHaveAttribute('data-authed', 'false');
expect(screen.queryByTestId('navigate')).not.toBeInTheDocument();
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/src/assets/landing/dashboard-screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion app/src/pages/custom_rules/RuleComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ export function RuleComposer({
Input: CustomInput,
}}
classNamePrefix="rule-composer"
placeholder="Paste or type domains, IPs, or ASNs"
placeholder="Domain, IP, or ASN"
value={tokens}
inputValue={inputValue}
onChange={handleChange}
Expand Down
Loading
Loading