diff --git a/frontend/app/[locale]/login/page.tsx b/frontend/app/[locale]/login/page.tsx index f8cea2ed..f03dbc2f 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 | 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 ; + 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 }); } 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..622a2ba6 --- /dev/null +++ b/frontend/components/auth/LastLoginBadge.tsx @@ -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 ( + + {t('lastUsed')} + + ); +} diff --git a/frontend/components/auth/LoginForm.tsx b/frontend/components/auth/LoginForm.tsx index 7bfda2ad..79da8d47 100644 --- a/frontend/components/auth/LoginForm.tsx +++ b/frontend/components/auth/LoginForm.tsx @@ -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(null); @@ -118,7 +127,7 @@ export function LoginForm({ locale, returnTo }: LoginFormProps) {

} > - +
@@ -164,9 +173,19 @@ 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..08561ad3 100644 --- a/frontend/components/auth/ProviderButton.tsx +++ b/frontend/components/auth/ProviderButton.tsx @@ -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 ( - +
+ + {isLastUsed && } +
); } diff --git a/frontend/components/tests/LastLoginBadge.test.tsx b/frontend/components/tests/LastLoginBadge.test.tsx new file mode 100644 index 00000000..bfd082c5 --- /dev/null +++ b/frontend/components/tests/LastLoginBadge.test.tsx @@ -0,0 +1,50 @@ +/** + * @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(); + expect( + screen.getByRole('button', { name: 'Continue with Google' }) + ).toHaveAccessibleDescription('lastUsed'); + }); + + it('hides the badge when isLastUsed is false', () => { + render( + + ); + expect(screen.queryByText('lastUsed')).toBeNull(); + expect( + screen.getByRole('button', { name: 'Continue with GitHub' }) + ).not.toHaveAttribute('aria-describedby'); + }); +}); diff --git a/frontend/lib/auth-last-login.ts b/frontend/lib/auth-last-login.ts new file mode 100644 index 00000000..9d41847b --- /dev/null +++ b/frontend/lib/auth-last-login.ts @@ -0,0 +1,31 @@ +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 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(); + + 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 LAST_LOGIN_METHODS.includes(value as LastLoginMethod) + ? (value as LastLoginMethod) + : null; +} 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(''); + }); +}); 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": "Реєстрація",