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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -1079,3 +1079,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

- Prevented locale layout rendering from failing when blog category loading throws at runtime
- Prevented duplicate slashes in homepage canonical URLs when `NEXT_PUBLIC_SITE_URL` ends with `/`

## [1.0.14] - 2026-08-18

### Added

- Last-used login method indicator:
- Remembers successful email, Google, or GitHub sign-in in a secure, HTTP-only cookie
- Highlights the matching option when the user returns to the login page
- Adds localized labels for English, Ukrainian, and Polish
- Includes unit coverage for badge rendering and accessibility wiring

### Changed

- Homepage delivery:
- Converted the Features section to an async Server Component with server-side translations
- Added a Suspense boundary so the Features section can stream independently
- Deferred mounting and loading the Footer client chunk until it is within 300px of the viewport
- Removed unused homepage state and React imports
- About page social metric:
- Updated the LinkedIn followers fallback to `2.4k`
- Refreshed the cached platform-statistics key so the new value is served immediately
- Test tooling security:
- Updated Vitest and its V8 coverage provider from vulnerable `4.0.x` versions to patched `4.1.10`

### Fixed

- Hardened post-login redirect validation against duplicate query values, external URLs, protocol-relative URLs, backslashes, and control-character normalization bypasses
- Added accessible descriptions linking the “Last used” badge to email and OAuth login buttons
29 changes: 19 additions & 10 deletions frontend/app/[locale]/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
'use client';

import { useSearchParams } from 'next/navigation';
import { useLocale } from 'next-intl';

import { LoginForm } from '@/components/auth/LoginForm';
import { getSafeRedirect } from '@/lib/auth/safe-redirect';
import { getLastLoginMethod } from '@/lib/auth-last-login';

export default function LoginPage() {
const locale = useLocale();
const searchParams = useSearchParams();
export default async function LoginPage({
params,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<{ returnTo?: string | string[] }>;
}) {
const { locale } = await params;
const { returnTo: returnToParam } = await searchParams;

const returnTo = getSafeRedirect(searchParams.get('returnTo'));
const returnTo = getSafeRedirect(returnToParam);
const lastLoginMethod = await getLastLoginMethod();

return <LoginForm locale={locale} returnTo={returnTo} />;
return (
<LoginForm
locale={locale}
returnTo={returnTo}
lastLoginMethod={lastLoginMethod}
/>
);
}
9 changes: 6 additions & 3 deletions frontend/app/[locale]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { getTranslations } from 'next-intl/server';
import { Suspense } from 'react';

import FeaturesHeroSection from '@/components/home/FeaturesHeroSection';
import HomePageScroll from '@/components/home/HomePageScroll';
import WelcomeHeroSection from '@/components/home/WelcomeHeroSection';
import Footer from '@/components/shared/Footer';
import LazyFooter from '@/components/shared/LazyFooter';

export async function generateMetadata({
params,
Expand Down Expand Up @@ -75,9 +76,11 @@ export default function Home() {
data-home-step
className="min-h-[calc(100dvh-4rem)] shrink-0 snap-start [scroll-snap-stop:always]"
>
<FeaturesHeroSection />
<Suspense fallback={null}>
<FeaturesHeroSection />
</Suspense>
</div>
<Footer forceVisible />
<LazyFooter forceVisible />
</HomePageScroll>
);
}
2 changes: 2 additions & 0 deletions frontend/app/api/auth/github/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { db } from '@/db';
import { users } from '@/db/schema/users';
import { setAuthCookie, signAuthToken } from '@/lib/auth';
import { consumeOAuthState } from '@/lib/auth/oauth-state';
import { setLastLoginMethodCookie } from '@/lib/auth-last-login';
import { authEnv } from '@/lib/env/auth';

type GithubTokenResponse = {
Expand Down Expand Up @@ -163,6 +164,7 @@ export async function GET(req: NextRequest) {
});

await setAuthCookie(token);
await setLastLoginMethodCookie('github');

return NextResponse.redirect(new URL('/dashboard', req.url));
}
2 changes: 2 additions & 0 deletions frontend/app/api/auth/google/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { db } from '@/db';
import { users } from '@/db/schema/users';
import { setAuthCookie, signAuthToken } from '@/lib/auth';
import { consumeOAuthState } from '@/lib/auth/oauth-state';
import { setLastLoginMethodCookie } from '@/lib/auth-last-login';
import { authEnv } from '@/lib/env/auth';

type GoogleTokenResponse = {
Expand Down Expand Up @@ -149,6 +150,7 @@ export async function GET(req: NextRequest) {
});

await setAuthCookie(token);
await setLastLoginMethodCookie('google');

return NextResponse.redirect(new URL('/dashboard', req.url));
}
2 changes: 2 additions & 0 deletions frontend/app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { z } from 'zod';
import { db } from '@/db';
import { users } from '@/db/schema/users';
import { setAuthCookie, signAuthToken } from '@/lib/auth';
import { setLastLoginMethodCookie } from '@/lib/auth-last-login';

export const runtime = 'nodejs';

Expand Down Expand Up @@ -78,6 +79,7 @@ export async function POST(req: Request) {
});

await setAuthCookie(token);
await setLastLoginMethodCookie('email');
revalidatePath('/[locale]', 'layout');
return NextResponse.json({ success: true, userId: result[0].id });
}
2 changes: 1 addition & 1 deletion frontend/components/about/HeroSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function HeroSection({ stats }: { stats?: PlatformStats }) {
questionsSolved: '850+',
githubStars: '120+',
activeUsers: '200+',
linkedinFollowers: '2k+',
linkedinFollowers: '2.4k',
};

return (
Expand Down
11 changes: 7 additions & 4 deletions frontend/components/auth/AuthProvidersBlock.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
'use client';

import { useTranslations } from 'next-intl';

import { OAuthButtons } from '@/components/auth/OAuthButtons';
import type { LastLoginMethod } from '@/lib/auth-last-login';

export function AuthProvidersBlock() {
export function AuthProvidersBlock({
lastLoginMethod = null,
}: {
lastLoginMethod?: LastLoginMethod | null;
}) {
const t = useTranslations('auth');

return (
<>
<OAuthButtons />
<OAuthButtons lastLoginMethod={lastLoginMethod} />

<div className="flex items-center gap-3">
<div className="h-px flex-1 bg-gray-200" />
Expand Down
17 changes: 17 additions & 0 deletions frontend/components/auth/LastLoginBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useTranslations } from 'next-intl';

import { Badge } from '@/components/ui/badge';

export function LastLoginBadge({ id }: { id?: string }) {
const t = useTranslations('auth.login');

return (
<Badge
id={id}
variant="success"
className="pointer-events-none absolute -top-1.5 -right-1.5 z-10 dark:bg-green-900"
>
{t('lastUsed')}
</Badge>
);
}
29 changes: 24 additions & 5 deletions frontend/components/auth/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,25 @@ import { AuthShell } from '@/components/auth/AuthShell';
import { AuthSuccessBanner } from '@/components/auth/AuthSuccessBanner';
import { EmailField } from '@/components/auth/fields/EmailField';
import { PasswordField } from '@/components/auth/fields/PasswordField';
import { LastLoginBadge } from '@/components/auth/LastLoginBadge';
import { Button } from '@/components/ui/button';
import { Link } from '@/i18n/routing';
import type { LastLoginMethod } from '@/lib/auth-last-login';
import { broadcastAuthUpdated } from '@/lib/auth-sync';

type LoginFormProps = {
locale: string;
returnTo: string;
lastLoginMethod: LastLoginMethod | null;
};

export function LoginForm({ locale, returnTo }: LoginFormProps) {
export function LoginForm({
locale,
returnTo,
lastLoginMethod,
}: LoginFormProps) {
const emailLastLoginBadgeId =
lastLoginMethod === 'email' ? 'last-login-email-badge' : undefined;
const t = useTranslations('auth.login');
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
Expand Down Expand Up @@ -118,7 +127,7 @@ export function LoginForm({ locale, returnTo }: LoginFormProps) {
</p>
}
>
<AuthProvidersBlock />
<AuthProvidersBlock lastLoginMethod={lastLoginMethod} />

<form onSubmit={onSubmit} className="space-y-4">
<EmailField onChange={setEmail} />
Expand Down Expand Up @@ -164,9 +173,19 @@ export function LoginForm({ locale, returnTo }: LoginFormProps) {
/>
)}

<Button type="submit" disabled={loading} className="w-full">
{loading ? t('submitting') : t('submit')}
</Button>
<div className="relative">
<Button
type="submit"
disabled={loading}
aria-describedby={emailLastLoginBadgeId}
className="w-full"
>
{loading ? t('submitting') : t('submit')}
</Button>
{lastLoginMethod === 'email' && (
<LastLoginBadge id={emailLastLoginBadgeId} />
)}
</div>
</form>
</AuthShell>
);
Expand Down
10 changes: 9 additions & 1 deletion frontend/components/auth/OAuthButtons.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
import type { LastLoginMethod } from '@/lib/auth-last-login';

import { GitHubIcon } from './icons/GitHubIcon';
import { GoogleIcon } from './icons/GoogleIcon';
import { ProviderButton } from './ProviderButton';

export function OAuthButtons() {
export function OAuthButtons({
lastLoginMethod = null,
}: {
lastLoginMethod?: LastLoginMethod | null;
}) {
return (
<div className="space-y-2">
<ProviderButton
provider="google"
label="Continue with Google"
icon={<GoogleIcon className="h-4 w-4" />}
isLastUsed={lastLoginMethod === 'google'}
/>

<ProviderButton
provider="github"
label="Continue with GitHub"
icon={<GitHubIcon className="h-4 w-4" />}
isLastUsed={lastLoginMethod === 'github'}
/>
</div>
);
Expand Down
35 changes: 25 additions & 10 deletions frontend/components/auth/ProviderButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,43 @@

import { ReactNode } from 'react';

import { LastLoginBadge } from '@/components/auth/LastLoginBadge';
import { Button } from '@/components/ui/button';

type ProviderButtonProps = {
provider: 'google' | 'github';
label: string;
icon: ReactNode;
isLastUsed?: boolean;
};

export function ProviderButton({ provider, label, icon }: ProviderButtonProps) {
export function ProviderButton({
provider,
label,
icon,
isLastUsed,
}: ProviderButtonProps) {
const lastLoginBadgeId = isLastUsed
? `last-login-${provider}-badge`
: undefined;

function oauthLogin() {
window.location.href = `/api/auth/${provider}`;
}

return (
<Button
type="button"
variant="outline"
className="flex w-full items-center justify-center gap-2"
onClick={oauthLogin}
>
{icon}
<span>{label}</span>
</Button>
<div className="relative">
<Button
type="button"
variant="outline"
aria-describedby={lastLoginBadgeId}
className="flex w-full items-center justify-center gap-2"
onClick={oauthLogin}
>
{icon}
<span>{label}</span>
</Button>
{isLastUsed && <LastLoginBadge id={lastLoginBadgeId} />}
</div>
);
}
9 changes: 3 additions & 6 deletions frontend/components/home/FeaturesHeroSection.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
'use client';

import { BrainCircuit, MessageCircleQuestion, TrendingUp } from 'lucide-react';
import { useTranslations } from 'next-intl';
import * as React from 'react';
import { getTranslations } from 'next-intl/server';

import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground';
import { Link } from '@/i18n/routing';

import { FlipCardQA } from './FlipCardQA';
import { FloatingCode } from './FloatingCode';

export default function FeaturesHeroSection() {
const t = useTranslations('homepage');
export default async function FeaturesHeroSection() {
const t = await getTranslations('homepage');

return (
<DynamicGridBackground
Expand Down
5 changes: 0 additions & 5 deletions frontend/components/home/InteractiveCTAButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ export const InteractiveCTAButton = React.forwardRef<HTMLAnchorElement>(

const [currentText, setCurrentText] = useState(t('cta'));
const [variantIndex, setVariantIndex] = useState(0);
const [isFirstRender, setIsFirstRender] = useState(true);

const textVariants = [
t('cta'),
Expand All @@ -36,10 +35,6 @@ export const InteractiveCTAButton = React.forwardRef<HTMLAnchorElement>(
t('ctaVariants.8'),
];

useEffect(() => {
setIsFirstRender(false);
}, []);

const x = useMotionValue(0);
const y = useMotionValue(0);

Expand Down
2 changes: 0 additions & 2 deletions frontend/components/home/WelcomeHeroBackground.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import React from 'react';

export function WelcomeHeroBackground() {
return (
<>
Expand Down
Loading
Loading