From b263bb1b3f67b3d68eed5cd0587ce79a44ccf3fd Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Sat, 15 Aug 2026 16:57:03 +0200 Subject: [PATCH 1/5] feat(messages): Add lastUsed translations --- frontend/messages/en.json | 3 ++- frontend/messages/pl.json | 3 ++- frontend/messages/uk.json | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 21bfddd0..dea03c1f 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1472,7 +1472,8 @@ "resendFailed": "Failed to resend verification email. Please try again." }, "resendVerification": "Resend verification email", - "verificationSent": "Verification successfully sent to" + "verificationSent": "Verification successfully sent to", + "lastUsed": "Last used" }, "signup": { "title": "Sign up", diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index 4ceca896..5399cd1a 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -1473,7 +1473,8 @@ "resendFailed": "Nie udało się ponownie wysłać emaila weryfikacyjnego. Spróbuj ponownie." }, "resendVerification": "Wyślij ponownie email weryfikacyjny", - "verificationSent": "Email weryfikacyjny został wysłany na" + "verificationSent": "Email weryfikacyjny został wysłany na", + "lastUsed": "Ostatnio używane" }, "signup": { "title": "Rejestracja", diff --git a/frontend/messages/uk.json b/frontend/messages/uk.json index fc161ad7..2d14a49e 100644 --- a/frontend/messages/uk.json +++ b/frontend/messages/uk.json @@ -1473,7 +1473,8 @@ "resendFailed": "Не вдалося повторно надіслати лист підтвердження. Спробуйте ще раз." }, "resendVerification": "Надіслати лист підтвердження повторно", - "verificationSent": "Лист підтвердження успішно надіслано на" + "verificationSent": "Лист підтвердження успішно надіслано на", + "lastUsed": "Востаннє використано" }, "signup": { "title": "Реєстрація", From 13c5f468df85c3cc0f9a6c44b69f7268f363f5e1 Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Sat, 15 Aug 2026 17:01:27 +0200 Subject: [PATCH 2/5] feat(auth): Add last login badge Introduce LastLoginBadge and show it on the last-used provider button and the email submit. Thread lastLoginMethod prop through AuthProvidersBlock, OAuthButtons, ProviderButton, and LoginForm. Add unit tests for badge rendering. --- .../components/auth/AuthProvidersBlock.tsx | 11 +++-- frontend/components/auth/LastLoginBadge.tsx | 16 +++++++ frontend/components/auth/LoginForm.tsx | 20 ++++++--- frontend/components/auth/OAuthButtons.tsx | 10 ++++- frontend/components/auth/ProviderButton.tsx | 30 ++++++++----- .../components/tests/LastLoginBadge.test.tsx | 44 +++++++++++++++++++ frontend/lib/auth-last-login.ts | 32 ++++++++++++++ 7 files changed, 143 insertions(+), 20 deletions(-) create mode 100644 frontend/components/auth/LastLoginBadge.tsx create mode 100644 frontend/components/tests/LastLoginBadge.test.tsx create mode 100644 frontend/lib/auth-last-login.ts diff --git a/frontend/components/auth/AuthProvidersBlock.tsx b/frontend/components/auth/AuthProvidersBlock.tsx index 05b10456..9ea8665f 100644 --- a/frontend/components/auth/AuthProvidersBlock.tsx +++ b/frontend/components/auth/AuthProvidersBlock.tsx @@ -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 ( <> - +
diff --git a/frontend/components/auth/LastLoginBadge.tsx b/frontend/components/auth/LastLoginBadge.tsx new file mode 100644 index 00000000..aec51110 --- /dev/null +++ b/frontend/components/auth/LastLoginBadge.tsx @@ -0,0 +1,16 @@ +import { useTranslations } from 'next-intl'; + +import { Badge } from '@/components/ui/badge'; + +export function LastLoginBadge() { + const t = useTranslations('auth.login'); + + return ( + + {t('lastUsed')} + + ); +} diff --git a/frontend/components/auth/LoginForm.tsx b/frontend/components/auth/LoginForm.tsx index 7bfda2ad..8cccf6bb 100644 --- a/frontend/components/auth/LoginForm.tsx +++ b/frontend/components/auth/LoginForm.tsx @@ -9,16 +9,23 @@ 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 t = useTranslations('auth.login'); const [loading, setLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(null); @@ -118,7 +125,7 @@ export function LoginForm({ locale, returnTo }: LoginFormProps) {

} > - +
@@ -164,9 +171,12 @@ export function LoginForm({ locale, returnTo }: LoginFormProps) { /> )} - +
+ + {lastLoginMethod === 'email' && } +
); diff --git a/frontend/components/auth/OAuthButtons.tsx b/frontend/components/auth/OAuthButtons.tsx index 732add73..55cff878 100644 --- a/frontend/components/auth/OAuthButtons.tsx +++ b/frontend/components/auth/OAuthButtons.tsx @@ -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 (
} + isLastUsed={lastLoginMethod === 'google'} /> } + isLastUsed={lastLoginMethod === 'github'} />
); diff --git a/frontend/components/auth/ProviderButton.tsx b/frontend/components/auth/ProviderButton.tsx index b874ed45..3632e50a 100644 --- a/frontend/components/auth/ProviderButton.tsx +++ b/frontend/components/auth/ProviderButton.tsx @@ -2,28 +2,38 @@ 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) { function oauthLogin() { window.location.href = `/api/auth/${provider}`; } return ( - +
+ + {isLastUsed && } +
); } diff --git a/frontend/components/tests/LastLoginBadge.test.tsx b/frontend/components/tests/LastLoginBadge.test.tsx new file mode 100644 index 00000000..fa7088d7 --- /dev/null +++ b/frontend/components/tests/LastLoginBadge.test.tsx @@ -0,0 +1,44 @@ +/** + * @vitest-environment jsdom + */ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { LastLoginBadge } from '@/components/auth/LastLoginBadge'; +import { ProviderButton } from '@/components/auth/ProviderButton'; + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})); + +describe('LastLoginBadge', () => { + it('renders the "last used" label', () => { + render(); + expect(screen.getByText('lastUsed')).toBeDefined(); + }); +}); + +describe('ProviderButton', () => { + it('shows the badge when isLastUsed is true', () => { + render( + + ); + expect(screen.getByText('lastUsed')).toBeDefined(); + }); + + it('hides the badge when isLastUsed is false', () => { + render( + + ); + expect(screen.queryByText('lastUsed')).toBeNull(); + }); +}); diff --git a/frontend/lib/auth-last-login.ts b/frontend/lib/auth-last-login.ts new file mode 100644 index 00000000..7aff3ac4 --- /dev/null +++ b/frontend/lib/auth-last-login.ts @@ -0,0 +1,32 @@ +import 'server-only'; + +import { cookies } from 'next/headers'; + +const LAST_LOGIN_COOKIE = 'last_login_method'; +const LAST_LOGIN_MAX_AGE = 60 * 60 * 24 * 365; + +export type LastLoginMethod = 'email' | 'google' | 'github'; + +const VALID_METHODS: readonly LastLoginMethod[] = ['email', 'google', 'github']; + +export async function setLastLoginMethodCookie(method: LastLoginMethod) { + const cookieStore = await cookies(); + + cookieStore.set(LAST_LOGIN_COOKIE, method, { + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + path: '/', + maxAge: LAST_LOGIN_MAX_AGE, + }); +} + +export async function getLastLoginMethod(): Promise { + const cookieStore = await cookies(); + + const value = cookieStore.get(LAST_LOGIN_COOKIE)?.value; + + return VALID_METHODS.includes(value as LastLoginMethod) + ? (value as LastLoginMethod) + : null; +} From b0668c7d5741344aa52f6aff466166dfe6c681f2 Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Sat, 15 Aug 2026 17:02:37 +0200 Subject: [PATCH 3/5] feat(auth): store last login method Record last login provider in a cookie for email, Google, and GitHub callbacks. Convert the login page to an async server component to read params/searchParams, retrieve the last login method, and pass it into LoginForm for UI use. --- frontend/app/[locale]/login/page.tsx | 29 ++++++++++++------- .../app/api/auth/github/callback/route.ts | 2 ++ .../app/api/auth/google/callback/route.ts | 2 ++ frontend/app/api/auth/login/route.ts | 2 ++ 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/frontend/app/[locale]/login/page.tsx b/frontend/app/[locale]/login/page.tsx index f8cea2ed..7445afc0 100644 --- a/frontend/app/[locale]/login/page.tsx +++ b/frontend/app/[locale]/login/page.tsx @@ -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 }>; +}) { + const { locale } = await params; + const { returnTo: returnToParam } = await searchParams; - const returnTo = getSafeRedirect(searchParams.get('returnTo')); + const returnTo = getSafeRedirect(returnToParam ?? null); + const lastLoginMethod = await getLastLoginMethod(); - return ; + return ( + + ); } diff --git a/frontend/app/api/auth/github/callback/route.ts b/frontend/app/api/auth/github/callback/route.ts index 2df81a6a..ac104c6b 100644 --- a/frontend/app/api/auth/github/callback/route.ts +++ b/frontend/app/api/auth/github/callback/route.ts @@ -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 = { @@ -163,6 +164,7 @@ export async function GET(req: NextRequest) { }); await setAuthCookie(token); + await setLastLoginMethodCookie('github'); return NextResponse.redirect(new URL('/dashboard', req.url)); } diff --git a/frontend/app/api/auth/google/callback/route.ts b/frontend/app/api/auth/google/callback/route.ts index b25a79b5..41415557 100644 --- a/frontend/app/api/auth/google/callback/route.ts +++ b/frontend/app/api/auth/google/callback/route.ts @@ -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 = { @@ -149,6 +150,7 @@ export async function GET(req: NextRequest) { }); await setAuthCookie(token); + await setLastLoginMethodCookie('google'); return NextResponse.redirect(new URL('/dashboard', req.url)); } diff --git a/frontend/app/api/auth/login/route.ts b/frontend/app/api/auth/login/route.ts index 21d1d5e5..f257e420 100644 --- a/frontend/app/api/auth/login/route.ts +++ b/frontend/app/api/auth/login/route.ts @@ -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'; @@ -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 }); } From cd75e861bc65a6a68ddffd6f7f14145ce6458486 Mon Sep 17 00:00:00 2001 From: nazar-gavrylyk Date: Sat, 15 Aug 2026 17:17:59 +0200 Subject: [PATCH 4/5] refactor(auth): Export last login methods --- frontend/lib/auth-last-login.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/frontend/lib/auth-last-login.ts b/frontend/lib/auth-last-login.ts index 7aff3ac4..9d41847b 100644 --- a/frontend/lib/auth-last-login.ts +++ b/frontend/lib/auth-last-login.ts @@ -5,9 +5,8 @@ import { cookies } from 'next/headers'; const LAST_LOGIN_COOKIE = 'last_login_method'; const LAST_LOGIN_MAX_AGE = 60 * 60 * 24 * 365; -export type LastLoginMethod = 'email' | 'google' | 'github'; - -const VALID_METHODS: readonly LastLoginMethod[] = ['email', 'google', 'github']; +export const LAST_LOGIN_METHODS = ['email', 'google', 'github'] as const; +export type LastLoginMethod = (typeof LAST_LOGIN_METHODS)[number]; export async function setLastLoginMethodCookie(method: LastLoginMethod) { const cookieStore = await cookies(); @@ -26,7 +25,7 @@ export async function getLastLoginMethod(): Promise { const value = cookieStore.get(LAST_LOGIN_COOKIE)?.value; - return VALID_METHODS.includes(value as LastLoginMethod) + return LAST_LOGIN_METHODS.includes(value as LastLoginMethod) ? (value as LastLoginMethod) : null; } From d1c52470d7a4d33dbb9919758ed65f03ba5efa04 Mon Sep 17 00:00:00 2001 From: Viktor Svertoka Date: Sun, 16 Aug 2026 11:43:26 +0300 Subject: [PATCH 5/5] fix(auth): harden redirect and badge accessibility --- frontend/app/[locale]/login/page.tsx | 4 ++-- frontend/components/auth/LastLoginBadge.tsx | 3 ++- frontend/components/auth/LoginForm.tsx | 13 +++++++++++-- frontend/components/auth/ProviderButton.tsx | 7 ++++++- .../components/tests/LastLoginBadge.test.tsx | 6 ++++++ frontend/lib/auth/safe-redirect.ts | 5 ++++- frontend/lib/tests/safe-redirect.test.ts | 17 +++++++++++++++++ 7 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 frontend/lib/tests/safe-redirect.test.ts diff --git a/frontend/app/[locale]/login/page.tsx b/frontend/app/[locale]/login/page.tsx index 7445afc0..f03dbc2f 100644 --- a/frontend/app/[locale]/login/page.tsx +++ b/frontend/app/[locale]/login/page.tsx @@ -7,12 +7,12 @@ export default async function LoginPage({ searchParams, }: { params: Promise<{ locale: string }>; - searchParams: Promise<{ returnTo?: string }>; + searchParams: Promise<{ returnTo?: string | string[] }>; }) { const { locale } = await params; const { returnTo: returnToParam } = await searchParams; - const returnTo = getSafeRedirect(returnToParam ?? null); + const returnTo = getSafeRedirect(returnToParam); const lastLoginMethod = await getLastLoginMethod(); return ( diff --git a/frontend/components/auth/LastLoginBadge.tsx b/frontend/components/auth/LastLoginBadge.tsx index aec51110..622a2ba6 100644 --- a/frontend/components/auth/LastLoginBadge.tsx +++ b/frontend/components/auth/LastLoginBadge.tsx @@ -2,11 +2,12 @@ import { useTranslations } from 'next-intl'; import { Badge } from '@/components/ui/badge'; -export function LastLoginBadge() { +export function LastLoginBadge({ id }: { id?: string }) { const t = useTranslations('auth.login'); return ( diff --git a/frontend/components/auth/LoginForm.tsx b/frontend/components/auth/LoginForm.tsx index 8cccf6bb..79da8d47 100644 --- a/frontend/components/auth/LoginForm.tsx +++ b/frontend/components/auth/LoginForm.tsx @@ -26,6 +26,8 @@ export function LoginForm({ 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(null); @@ -172,10 +174,17 @@ export function LoginForm({ )}
- - {lastLoginMethod === 'email' && } + {lastLoginMethod === 'email' && ( + + )}
diff --git a/frontend/components/auth/ProviderButton.tsx b/frontend/components/auth/ProviderButton.tsx index 3632e50a..08561ad3 100644 --- a/frontend/components/auth/ProviderButton.tsx +++ b/frontend/components/auth/ProviderButton.tsx @@ -18,6 +18,10 @@ export function ProviderButton({ icon, isLastUsed, }: ProviderButtonProps) { + const lastLoginBadgeId = isLastUsed + ? `last-login-${provider}-badge` + : undefined; + function oauthLogin() { window.location.href = `/api/auth/${provider}`; } @@ -27,13 +31,14 @@ export function ProviderButton({ - {isLastUsed && } + {isLastUsed && }
); } diff --git a/frontend/components/tests/LastLoginBadge.test.tsx b/frontend/components/tests/LastLoginBadge.test.tsx index fa7088d7..bfd082c5 100644 --- a/frontend/components/tests/LastLoginBadge.test.tsx +++ b/frontend/components/tests/LastLoginBadge.test.tsx @@ -29,6 +29,9 @@ describe('ProviderButton', () => { /> ); expect(screen.getByText('lastUsed')).toBeDefined(); + expect( + screen.getByRole('button', { name: 'Continue with Google' }) + ).toHaveAccessibleDescription('lastUsed'); }); it('hides the badge when isLastUsed is false', () => { @@ -40,5 +43,8 @@ describe('ProviderButton', () => { /> ); expect(screen.queryByText('lastUsed')).toBeNull(); + expect( + screen.getByRole('button', { name: 'Continue with GitHub' }) + ).not.toHaveAttribute('aria-describedby'); }); }); diff --git a/frontend/lib/auth/safe-redirect.ts b/frontend/lib/auth/safe-redirect.ts index 10b170b7..2071448a 100644 --- a/frontend/lib/auth/safe-redirect.ts +++ b/frontend/lib/auth/safe-redirect.ts @@ -1,4 +1,7 @@ -export function getSafeRedirect(raw: string | null | undefined): string { +export function getSafeRedirect( + raw: string | string[] | null | undefined +): string { + if (Array.isArray(raw)) return ''; if (!raw) return ''; if (raw.includes('\\')) return ''; diff --git a/frontend/lib/tests/safe-redirect.test.ts b/frontend/lib/tests/safe-redirect.test.ts new file mode 100644 index 00000000..f19018b0 --- /dev/null +++ b/frontend/lib/tests/safe-redirect.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import { getSafeRedirect } from '@/lib/auth/safe-redirect'; + +describe('getSafeRedirect', () => { + it('allows a safe internal path', () => { + expect(getSafeRedirect('/dashboard')).toBe('/dashboard'); + }); + + it('rejects duplicate returnTo query values', () => { + expect(getSafeRedirect(['/dashboard', '/shop'])).toBe(''); + }); + + it('rejects an external redirect', () => { + expect(getSafeRedirect('https://example.com')).toBe(''); + }); +});