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) {
}
>
-
+
);
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": "Реєстрація",