mirror of
https://github.com/meienberger/runtipi.git
synced 2024-08-17 08:00:26 +03:00
feat: move login page to RSC
This commit is contained in:
parent
a8933e592e
commit
32ab0da985
@ -6,6 +6,7 @@ const nextConfig = {
|
||||
transpilePackages: ['@runtipi/shared'],
|
||||
experimental: {
|
||||
serverComponentsExternalPackages: ['bullmq'],
|
||||
serverActions: true,
|
||||
},
|
||||
serverRuntimeConfig: {
|
||||
INTERNAL_IP: process.env.INTERNAL_IP,
|
||||
|
@ -13,7 +13,7 @@
|
||||
"test:vite": "dotenv -e .env.test -- vitest run --coverage",
|
||||
"dev": "npm run db:migrate && next dev",
|
||||
"dev:watcher": "pnpm -r --filter cli dev",
|
||||
"db:migrate": "NODE_ENV=development dotenv -e .env -- tsx ./src/server/run-migrations-dev.ts",
|
||||
"db:migrate": "NODE_ENV=development dotenv -e .env.local -- tsx ./src/server/run-migrations-dev.ts",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix",
|
||||
"build": "next build",
|
||||
@ -62,6 +62,7 @@
|
||||
"lodash.merge": "^4.6.2",
|
||||
"next": "13.4.19",
|
||||
"next-intl": "^2.20.0",
|
||||
"next-safe-action": "^3.0.1",
|
||||
"pg": "^8.11.1",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"react": "18.2.0",
|
||||
|
@ -1,5 +1,9 @@
|
||||
lockfileVersion: '6.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@ -94,6 +98,9 @@ importers:
|
||||
next-intl:
|
||||
specifier: ^2.20.0
|
||||
version: 2.20.0(next@13.4.19)(react@18.2.0)
|
||||
next-safe-action:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1(next@13.4.19)(react@18.2.0)(zod@3.21.4)
|
||||
pg:
|
||||
specifier: ^8.11.1
|
||||
version: 8.11.1
|
||||
@ -995,8 +1002,8 @@ packages:
|
||||
'@commitlint/types': 17.4.4
|
||||
'@types/node': 20.4.7
|
||||
chalk: 4.1.2
|
||||
cosmiconfig: 8.3.4(typescript@4.7.4)
|
||||
cosmiconfig-typescript-loader: 4.4.0(@types/node@20.4.7)(cosmiconfig@8.3.4)(ts-node@10.9.1)(typescript@4.7.4)
|
||||
cosmiconfig: 8.3.5(typescript@4.7.4)
|
||||
cosmiconfig-typescript-loader: 4.4.0(@types/node@20.4.7)(cosmiconfig@8.3.5)(ts-node@10.9.1)(typescript@4.7.4)
|
||||
lodash.isplainobject: 4.0.6
|
||||
lodash.merge: 4.6.2
|
||||
lodash.uniq: 4.5.0
|
||||
@ -5194,6 +5201,7 @@ packages:
|
||||
/clone@1.0.4:
|
||||
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
|
||||
engines: {node: '>=0.8'}
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
|
||||
/clsx@1.2.1:
|
||||
@ -5430,7 +5438,7 @@ packages:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
dev: true
|
||||
|
||||
/cosmiconfig-typescript-loader@4.4.0(@types/node@20.4.7)(cosmiconfig@8.3.4)(ts-node@10.9.1)(typescript@4.7.4):
|
||||
/cosmiconfig-typescript-loader@4.4.0(@types/node@20.4.7)(cosmiconfig@8.3.5)(ts-node@10.9.1)(typescript@4.7.4):
|
||||
resolution: {integrity: sha512-BabizFdC3wBHhbI4kJh0VkQP9GkBfoHPydD0COMce1nJ1kJAB3F2TmJ/I7diULBKtmEWSwEbuN/KDtgnmUUVmw==}
|
||||
engines: {node: '>=v14.21.3'}
|
||||
peerDependencies:
|
||||
@ -5440,7 +5448,7 @@ packages:
|
||||
typescript: '>=4'
|
||||
dependencies:
|
||||
'@types/node': 20.4.7
|
||||
cosmiconfig: 8.3.4(typescript@4.7.4)
|
||||
cosmiconfig: 8.3.5(typescript@4.7.4)
|
||||
ts-node: 10.9.1(@types/node@18.6.2)(typescript@4.7.4)
|
||||
typescript: 4.7.4
|
||||
dev: true
|
||||
@ -5456,8 +5464,8 @@ packages:
|
||||
yaml: 1.10.2
|
||||
dev: false
|
||||
|
||||
/cosmiconfig@8.3.4(typescript@4.7.4):
|
||||
resolution: {integrity: sha512-SF+2P8+o/PTV05rgsAjDzL4OFdVXAulSfC/L19VaeVT7+tpOOSscCt2QLxDZ+CLxF2WOiq6y1K5asvs8qUJT/Q==}
|
||||
/cosmiconfig@8.3.5(typescript@4.7.4):
|
||||
resolution: {integrity: sha512-A5Xry3xfS96wy2qbiLkQLAg4JUrR2wvfybxj6yqLmrUfMAvhS3MZxIP2oQn0grgYIvJqzpeTEWu4vK0t+12NNw==}
|
||||
engines: {node: '>=14'}
|
||||
peerDependencies:
|
||||
typescript: '>=4.9.5'
|
||||
@ -8947,7 +8955,7 @@ packages:
|
||||
acorn: 8.8.2
|
||||
eslint-visitor-keys: 3.4.1
|
||||
espree: 9.5.2
|
||||
semver: 7.5.4
|
||||
semver: 7.5.3
|
||||
dev: true
|
||||
|
||||
/jsonc-parser@3.2.0:
|
||||
@ -10024,6 +10032,19 @@ packages:
|
||||
react: 18.2.0
|
||||
dev: true
|
||||
|
||||
/next-safe-action@3.0.1(next@13.4.19)(react@18.2.0)(zod@3.21.4):
|
||||
resolution: {integrity: sha512-qQOHz4Z1vnW9fKAl3+nmSoONtX8kvqJBJJ4PkRlkSF8AfFJnYp7PZ5qvtdIBTzxNoQLtM/CyVqlAM/6dCHJ62w==}
|
||||
engines: {node: '>=16'}
|
||||
peerDependencies:
|
||||
next: '>= 13.4.2'
|
||||
react: '>= 18.2.0'
|
||||
zod: '>= 3.0.0'
|
||||
dependencies:
|
||||
next: 13.4.19(@babel/core@7.22.5)(react-dom@18.2.0)(react@18.2.0)(sass@1.63.6)
|
||||
react: 18.2.0
|
||||
zod: 3.21.4
|
||||
dev: false
|
||||
|
||||
/next@13.4.19(@babel/core@7.22.5)(react-dom@18.2.0)(react@18.2.0)(sass@1.63.6):
|
||||
resolution: {integrity: sha512-HuPSzzAbJ1T4BD8e0bs6B9C1kWQ6gv8ykZoRWs5AQoiIuqbGHHdQO7Ljuvg05Q0Z24E2ABozHe6FxDvI6HfyAw==}
|
||||
engines: {node: '>=16.8.0'}
|
||||
@ -10092,6 +10113,7 @@ packages:
|
||||
/node-gyp-build-optional-packages@5.0.7:
|
||||
resolution: {integrity: sha512-YlCCc6Wffkx0kHkmam79GKvDQ6x+QZkMjFGrIMxgFNILFvGSbCp2fCBC55pGTT9gVaz8Na5CLmxt/urtzRv36w==}
|
||||
hasBin: true
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
@ -13190,7 +13212,3 @@ packages:
|
||||
/zwitch@2.0.4:
|
||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||
dev: false
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
33
src/app/(auth)/layout.tsx
Normal file
33
src/app/(auth)/layout.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import { getCurrentLocale } from 'src/utils/getCurrentLocale';
|
||||
import { LanguageSelector } from '../components/LanguageSelector';
|
||||
|
||||
export default async function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
const locale = getCurrentLocale();
|
||||
|
||||
return (
|
||||
<div className="page page-center">
|
||||
<div className="position-absolute top-0 mt-3 end-0 me-1 pb-4">
|
||||
<LanguageSelector locale={locale} />
|
||||
</div>
|
||||
<div className="container container-tight py-4">
|
||||
<div className="text-center mb-4">
|
||||
<Image
|
||||
alt="Tipi logo"
|
||||
src="/tipi.png"
|
||||
height={50}
|
||||
width={50}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="card card-md">
|
||||
<div className="card-body">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { useAction } from 'next-safe-action/hook';
|
||||
import React, { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { loginAction } from '@/actions/login/login-action';
|
||||
import { verifyTotpAction } from '@/actions/verify-totp/verify-totp-action';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { LoginForm } from '../LoginForm';
|
||||
import { TotpForm } from '../TotpForm';
|
||||
|
||||
export function LoginContainer() {
|
||||
const [totpSessionId, setTotpSessionId] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const loginMutation = useAction(loginAction, {
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
toast.error(data.failure.reason);
|
||||
} else if (data.success && data.totpSessionId) {
|
||||
setTotpSessionId(data.totpSessionId);
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const verifyTotpMutation = useAction(verifyTotpAction, {
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
toast.error(data.failure.reason);
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (totpSessionId) {
|
||||
return <TotpForm loading={verifyTotpMutation.isExecuting} onSubmit={(totpCode) => verifyTotpMutation.execute({ totpCode, totpSessionId })} />;
|
||||
}
|
||||
|
||||
return <LoginForm loading={loginMutation.isExecuting} onSubmit={({ email, password }) => loginMutation.execute({ username: email, password })} />;
|
||||
}
|
@ -4,8 +4,8 @@ import z from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '../../../../components/ui/Button';
|
||||
import { Input } from '../../../../components/ui/Input';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
type FormValues = { email: string; password: string };
|
||||
|
23
src/app/(auth)/login/page.tsx
Normal file
23
src/app/(auth)/login/page.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getUserFromCookie } from '@/server/common/session.helpers';
|
||||
import { AuthQueries } from '@/server/queries/auth/auth.queries';
|
||||
import { db } from '@/server/db';
|
||||
import { LoginContainer } from './components/LoginContainer';
|
||||
|
||||
export default async function LoginPage() {
|
||||
const authQueries = new AuthQueries(db);
|
||||
const isConfigured = await authQueries.getFirstOperator();
|
||||
|
||||
if (!isConfigured) {
|
||||
redirect('/register');
|
||||
}
|
||||
|
||||
const user = await getUserFromCookie();
|
||||
|
||||
if (user) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
|
||||
return <LoginContainer />;
|
||||
}
|
21
src/app/actions/change-locale/change-locale-action.ts
Normal file
21
src/app/actions/change-locale/change-locale-action.ts
Normal file
@ -0,0 +1,21 @@
|
||||
'use server';
|
||||
|
||||
import { z } from 'zod';
|
||||
import { getLocaleFromString } from '@/shared/internationalization/locales';
|
||||
import { cookies } from 'next/headers';
|
||||
import { action } from '@/lib/safe-action';
|
||||
|
||||
const input = z.object({
|
||||
newLocale: z.string(),
|
||||
});
|
||||
|
||||
export const changeLocaleAction = action(input, async ({ newLocale }) => {
|
||||
const locale = getLocaleFromString(newLocale);
|
||||
|
||||
const cookieStore = cookies();
|
||||
cookieStore.set('tipi-locale', locale);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
});
|
33
src/app/actions/login/login-action.ts
Normal file
33
src/app/actions/login/login-action.ts
Normal file
@ -0,0 +1,33 @@
|
||||
'use server';
|
||||
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/server/db';
|
||||
import { AuthServiceClass } from '@/server/services/auth/auth.service';
|
||||
import { action } from '@/lib/safe-action';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { handleActionError } from '../utils/handle-action-error';
|
||||
|
||||
const input = z.object({
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Given a username and password, logs in the user and returns a totpSessionId
|
||||
* if that user has 2FA enabled.
|
||||
*/
|
||||
export const loginAction = action(input, async ({ username, password }) => {
|
||||
try {
|
||||
const authService = new AuthServiceClass(db);
|
||||
|
||||
const { totpSessionId } = await authService.login({ username, password });
|
||||
|
||||
if (!totpSessionId) {
|
||||
revalidatePath('/login');
|
||||
}
|
||||
|
||||
return { totpSessionId, success: true };
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
});
|
20
src/app/actions/utils/handle-action-error.ts
Normal file
20
src/app/actions/utils/handle-action-error.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { MessageKey, TranslatedError } from '@/server/utils/errors';
|
||||
import { getTranslatorFromCookie } from '@/lib/get-translator';
|
||||
|
||||
/**
|
||||
* Given an error, returns a failure object with the translated error message.
|
||||
*/
|
||||
export const handleActionError = async (e: unknown) => {
|
||||
const message = e instanceof Error ? e.message : e;
|
||||
const errorVariables = e instanceof TranslatedError ? e.variableValues : {};
|
||||
|
||||
const translator = await getTranslatorFromCookie();
|
||||
const messageTranslated = translator(message as MessageKey, errorVariables);
|
||||
|
||||
return {
|
||||
success: false as const,
|
||||
failure: {
|
||||
reason: messageTranslated,
|
||||
},
|
||||
};
|
||||
};
|
26
src/app/actions/verify-totp/verify-totp-action.ts
Normal file
26
src/app/actions/verify-totp/verify-totp-action.ts
Normal file
@ -0,0 +1,26 @@
|
||||
'use server';
|
||||
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/server/db';
|
||||
import { AuthServiceClass } from '@/server/services/auth/auth.service';
|
||||
import { action } from '@/lib/safe-action';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { handleActionError } from '../utils/handle-action-error';
|
||||
|
||||
const input = z.object({
|
||||
totpCode: z.string(),
|
||||
totpSessionId: z.string(),
|
||||
});
|
||||
|
||||
export const verifyTotpAction = action(input, async ({ totpSessionId, totpCode }) => {
|
||||
try {
|
||||
const authService = new AuthServiceClass(db);
|
||||
await authService.verifyTotp({ totpSessionId, totpCode });
|
||||
|
||||
revalidatePath('/login');
|
||||
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return handleActionError(e);
|
||||
}
|
||||
});
|
43
src/app/components/LanguageSelector/LanguageSelector.tsx
Normal file
43
src/app/components/LanguageSelector/LanguageSelector.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useAction } from 'next-safe-action/hook';
|
||||
import { LOCALE_OPTIONS, Locale } from '@/shared/internationalization/locales';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { changeLocaleAction } from '@/actions/change-locale/change-locale-action';
|
||||
import { LanguageSelectorLabel } from './LanguageSelectorLabel';
|
||||
|
||||
type IProps = {
|
||||
showLabel?: boolean;
|
||||
locale: Locale;
|
||||
};
|
||||
|
||||
export const LanguageSelector = (props: IProps) => {
|
||||
const { locale: initialLocale } = props;
|
||||
const [locale, setLocale] = React.useState<Locale>(initialLocale);
|
||||
const { showLabel = false } = props;
|
||||
const router = useRouter();
|
||||
|
||||
const { execute } = useAction(changeLocaleAction, { onSuccess: () => router.refresh() });
|
||||
|
||||
const onChange = (newLocale: Locale) => {
|
||||
setLocale(newLocale);
|
||||
execute({ newLocale });
|
||||
};
|
||||
|
||||
return (
|
||||
<Select value={locale} defaultValue="en-US" onValueChange={onChange}>
|
||||
<SelectTrigger className="mb-3" name="language" label={showLabel && <LanguageSelectorLabel />}>
|
||||
<SelectValue placeholder="Language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOCALE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import { IconExternalLink } from '@tabler/icons-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export const LanguageSelectorLabel = () => {
|
||||
const t = useTranslations('settings.settings');
|
||||
|
||||
return (
|
||||
<span>
|
||||
{t('language')}
|
||||
<a href="https://crowdin.com/project/runtipi/invite?h=ae594e86cd807bc075310cab20a4aa921693663" target="_blank" rel="noreferrer">
|
||||
{t('help-translate')}
|
||||
<IconExternalLink className="ms-1 mb-1" size={16} />
|
||||
</a>
|
||||
</span>
|
||||
);
|
||||
};
|
1
src/app/components/LanguageSelector/index.ts
Normal file
1
src/app/components/LanguageSelector/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { LanguageSelector } from './LanguageSelector';
|
@ -2,13 +2,13 @@ import React from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { Inter } from 'next/font/google';
|
||||
import { cookies, headers } from 'next/headers';
|
||||
import { getLocaleFromString } from '@/shared/internationalization/locales';
|
||||
import merge from 'lodash.merge';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
|
||||
import './global.css';
|
||||
import clsx from 'clsx';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { getCurrentLocale } from '../utils/getCurrentLocale';
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
@ -21,13 +21,7 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const cookieStore = cookies();
|
||||
const cookieLocale = cookieStore.get('tipi-locale');
|
||||
|
||||
const headersList = headers();
|
||||
const browserLocale = headersList.get('accept-language');
|
||||
|
||||
const locale = getLocaleFromString(String(cookieLocale?.value || browserLocale || 'en'));
|
||||
const locale = getCurrentLocale();
|
||||
|
||||
const englishMessages = (await import(`../client/messages/en.json`)).default;
|
||||
const messages = (await import(`../client/messages/${locale}.json`)).default;
|
||||
@ -36,7 +30,10 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
return (
|
||||
<html lang={locale} className={clsx(inter.className, 'border-top-wide border-primary')}>
|
||||
<NextIntlClientProvider locale={locale} messages={mergedMessages}>
|
||||
<body>{children}</body>
|
||||
<body>
|
||||
{children}
|
||||
<Toaster />
|
||||
</body>
|
||||
</NextIntlClientProvider>
|
||||
</html>
|
||||
);
|
||||
|
@ -25,6 +25,7 @@ export const Input = React.forwardRef<HTMLInputElement, IProps>(({ onChange, onB
|
||||
)}
|
||||
{/* eslint-disable-next-line jsx-a11y/no-redundant-roles */}
|
||||
<input
|
||||
suppressHydrationWarning
|
||||
aria-label={name}
|
||||
role="textbox"
|
||||
disabled={disabled}
|
||||
|
@ -34,7 +34,7 @@ export const handlers = [
|
||||
getTRPCMock({
|
||||
path: ['auth', 'login'],
|
||||
type: 'mutation',
|
||||
response: {},
|
||||
response: { sessionId: faker.datatype.uuid() },
|
||||
}),
|
||||
getTRPCMock({
|
||||
path: ['auth', 'logout'],
|
||||
|
@ -1,202 +0,0 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
|
||||
import { getTRPCMock, getTRPCMockError } from '../../../../mocks/getTrpcMock';
|
||||
import { server } from '../../../../mocks/server';
|
||||
import { LoginContainer } from './LoginContainer';
|
||||
|
||||
const pushFn = jest.fn();
|
||||
jest.mock('next/router', () => {
|
||||
const actualRouter = jest.requireActual('next-router-mock');
|
||||
|
||||
return {
|
||||
...actualRouter,
|
||||
useRouter: () => ({
|
||||
...actualRouter.useRouter(),
|
||||
push: pushFn,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
pushFn.mockClear();
|
||||
});
|
||||
|
||||
describe('Test: LoginContainer', () => {
|
||||
it('should render without error', () => {
|
||||
// Arrange
|
||||
render(<LoginContainer />);
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Login')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have login button disabled if email and password are not provided', () => {
|
||||
// Arrange
|
||||
render(<LoginContainer />);
|
||||
const loginButton = screen.getByRole('button', { name: 'Login' });
|
||||
|
||||
// Assert
|
||||
expect(loginButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should have login button enabled if email and password are provided', () => {
|
||||
// Arrange
|
||||
render(<LoginContainer />);
|
||||
const loginButton = screen.getByRole('button', { name: 'Login' });
|
||||
const emailInput = screen.getByRole('textbox', { name: 'email' });
|
||||
const passwordInput = screen.getByRole('textbox', { name: 'password' });
|
||||
|
||||
// Act
|
||||
fireEvent.change(emailInput, { target: { value: faker.internet.email() } });
|
||||
fireEvent.change(passwordInput, { target: { value: faker.internet.password() } });
|
||||
|
||||
// Assert
|
||||
expect(loginButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it('should redirect to / upon successful login', async () => {
|
||||
// Arrange
|
||||
const email = faker.internet.email();
|
||||
const password = faker.internet.password();
|
||||
server.use(getTRPCMock({ path: ['auth', 'login'], type: 'mutation', response: {} }));
|
||||
render(<LoginContainer />);
|
||||
|
||||
// Act
|
||||
const loginButton = screen.getByRole('button', { name: 'Login' });
|
||||
const emailInput = screen.getByRole('textbox', { name: 'email' });
|
||||
const passwordInput = screen.getByRole('textbox', { name: 'password' });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: email } });
|
||||
fireEvent.change(passwordInput, { target: { value: password } });
|
||||
fireEvent.click(loginButton);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(pushFn).toHaveBeenCalledWith('/');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error message if login fails', async () => {
|
||||
// Arrange
|
||||
server.use(getTRPCMockError({ path: ['auth', 'login'], type: 'mutation', status: 500, message: 'my big error' }));
|
||||
render(<LoginContainer />);
|
||||
|
||||
// Act
|
||||
const loginButton = screen.getByRole('button', { name: 'Login' });
|
||||
const emailInput = screen.getByRole('textbox', { name: 'email' });
|
||||
const passwordInput = screen.getByRole('textbox', { name: 'password' });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'test@test.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'test' } });
|
||||
fireEvent.click(loginButton);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/my big error/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show totp form if totpSessionId is returned', async () => {
|
||||
// arrange
|
||||
const email = faker.internet.email();
|
||||
const password = faker.internet.password();
|
||||
const totpSessionId = faker.string.uuid();
|
||||
server.use(
|
||||
getTRPCMock({
|
||||
path: ['auth', 'login'],
|
||||
type: 'mutation',
|
||||
response: { totpSessionId },
|
||||
}),
|
||||
);
|
||||
render(<LoginContainer />);
|
||||
|
||||
// act
|
||||
const loginButton = screen.getByRole('button', { name: 'Login' });
|
||||
const emailInput = screen.getByRole('textbox', { name: 'email' });
|
||||
const passwordInput = screen.getByRole('textbox', { name: 'password' });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: email } });
|
||||
fireEvent.change(passwordInput, { target: { value: password } });
|
||||
fireEvent.click(loginButton);
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Two-factor authentication')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error message if totp code is invalid', async () => {
|
||||
// arrange
|
||||
const email = faker.internet.email();
|
||||
const password = faker.internet.password();
|
||||
const totpSessionId = faker.string.uuid();
|
||||
server.use(getTRPCMock({ path: ['auth', 'login'], type: 'mutation', response: { totpSessionId } }));
|
||||
server.use(getTRPCMockError({ path: ['auth', 'verifyTotp'], type: 'mutation', status: 500, message: 'Invalid totp code' }));
|
||||
render(<LoginContainer />);
|
||||
|
||||
// act
|
||||
const loginButton = screen.getByRole('button', { name: 'Login' });
|
||||
const emailInput = screen.getByRole('textbox', { name: 'email' });
|
||||
const passwordInput = screen.getByRole('textbox', { name: 'password' });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: email } });
|
||||
fireEvent.change(passwordInput, { target: { value: password } });
|
||||
fireEvent.click(loginButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Two-factor authentication')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const totpInputs = screen.getAllByRole('textbox', { name: /digit/ });
|
||||
|
||||
totpInputs.forEach((input, index) => {
|
||||
fireEvent.change(input, { target: { value: index } });
|
||||
});
|
||||
|
||||
const totpSubmitButton = screen.getByRole('button', { name: 'Confirm' });
|
||||
fireEvent.click(totpSubmitButton);
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Invalid totp code/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should redirect to / if totp is valid', async () => {
|
||||
// arrange
|
||||
const email = faker.internet.email();
|
||||
const password = faker.internet.password();
|
||||
const totpSessionId = faker.string.uuid();
|
||||
server.use(getTRPCMock({ path: ['auth', 'login'], type: 'mutation', response: { totpSessionId } }));
|
||||
server.use(getTRPCMock({ path: ['auth', 'verifyTotp'], type: 'mutation', response: true }));
|
||||
render(<LoginContainer />);
|
||||
|
||||
// act
|
||||
const loginButton = screen.getByRole('button', { name: 'Login' });
|
||||
const emailInput = screen.getByRole('textbox', { name: 'email' });
|
||||
const passwordInput = screen.getByRole('textbox', { name: 'password' });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: email } });
|
||||
fireEvent.change(passwordInput, { target: { value: password } });
|
||||
fireEvent.click(loginButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Two-factor authentication')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const totpInputs = screen.getAllByRole('textbox', { name: /digit/ });
|
||||
|
||||
totpInputs.forEach((input, index) => {
|
||||
fireEvent.change(input, { target: { value: index } });
|
||||
});
|
||||
|
||||
const totpSubmitButton = screen.getByRole('button', { name: 'Confirm' });
|
||||
fireEvent.click(totpSubmitButton);
|
||||
|
||||
// assert
|
||||
await waitFor(() => {
|
||||
expect(pushFn).toHaveBeenCalledWith('/');
|
||||
});
|
||||
});
|
||||
});
|
@ -1,51 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import type { MessageKey } from '@/server/utils/errors';
|
||||
import { trpc } from '../../../../utils/trpc';
|
||||
import { AuthFormLayout } from '../../components/AuthFormLayout';
|
||||
import { LoginForm } from '../../components/LoginForm';
|
||||
import { TotpForm } from '../../components/TotpForm';
|
||||
|
||||
type FormValues = { email: string; password: string };
|
||||
|
||||
export const LoginContainer: React.FC = () => {
|
||||
const t = useTranslations();
|
||||
const [totpSessionId, setTotpSessionId] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
const utils = trpc.useContext();
|
||||
const login = trpc.auth.login.useMutation({
|
||||
onError: (e) => toast.error(t(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables })),
|
||||
onSuccess: (data) => {
|
||||
if (data.totpSessionId) {
|
||||
setTotpSessionId(data.totpSessionId);
|
||||
} else {
|
||||
utils.auth.me.invalidate();
|
||||
router.push('/');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const verifyTotp = trpc.auth.verifyTotp.useMutation({
|
||||
onError: (e) => toast.error(t(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables })),
|
||||
onSuccess: () => {
|
||||
utils.auth.me.invalidate();
|
||||
router.push('/');
|
||||
},
|
||||
});
|
||||
|
||||
const handlerSubmit = (values: FormValues) => {
|
||||
login.mutate({ username: values.email, password: values.password });
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthFormLayout>
|
||||
{totpSessionId ? (
|
||||
<TotpForm onSubmit={(o) => verifyTotp.mutate({ totpCode: o, totpSessionId })} loading={verifyTotp.isLoading} />
|
||||
) : (
|
||||
<LoginForm onSubmit={handlerSubmit} loading={login.isLoading} />
|
||||
)}
|
||||
</AuthFormLayout>
|
||||
);
|
||||
};
|
@ -1,38 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '../../../../../../tests/test-utils';
|
||||
import { getTRPCMock } from '../../../../mocks/getTrpcMock';
|
||||
import { server } from '../../../../mocks/server';
|
||||
import { LoginPage } from './LoginPage';
|
||||
|
||||
const pushFn = jest.fn();
|
||||
jest.mock('next/router', () => {
|
||||
const actualRouter = jest.requireActual('next-router-mock');
|
||||
|
||||
return {
|
||||
...actualRouter,
|
||||
useRouter: () => ({
|
||||
...actualRouter.useRouter(),
|
||||
push: pushFn,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Test: LoginPage', () => {
|
||||
it('should render correctly', async () => {
|
||||
render(<LoginPage />);
|
||||
server.use(getTRPCMock({ path: ['auth', 'isConfigured'], response: true }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Login')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should redirect to register page when isConfigured is false', async () => {
|
||||
render(<LoginPage />);
|
||||
server.use(getTRPCMock({ path: ['auth', 'isConfigured'], response: false }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pushFn).toBeCalledWith('/register');
|
||||
});
|
||||
});
|
||||
});
|
@ -1,20 +0,0 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import React from 'react';
|
||||
import { StatusScreen } from '../../../../components/StatusScreen';
|
||||
import { trpc } from '../../../../utils/trpc';
|
||||
import { LoginContainer } from '../../containers/LoginContainer';
|
||||
|
||||
export const LoginPage = () => {
|
||||
const router = useRouter();
|
||||
const { data, isLoading } = trpc.auth.isConfigured.useQuery();
|
||||
|
||||
if (data === false) {
|
||||
router.push('/register');
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <StatusScreen title="" subtitle="" />;
|
||||
}
|
||||
|
||||
return <LoginContainer />;
|
||||
};
|
@ -1 +0,0 @@
|
||||
export { LoginPage } from './LoginPage';
|
20
src/lib/get-translator.ts
Normal file
20
src/lib/get-translator.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { getLocaleFromString } from '@/shared/internationalization/locales';
|
||||
import merge from 'lodash.merge';
|
||||
import { createTranslator } from 'next-intl';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export const getTranslatorFromCookie = async () => {
|
||||
const cookieStore = cookies();
|
||||
const cookieLocale = cookieStore.get('tipi-locale');
|
||||
|
||||
const locale = getLocaleFromString(cookieLocale?.value);
|
||||
|
||||
const englishMessages = (await import(`../client/messages/en.json`)).default;
|
||||
const messages = (await import(`../client/messages/${locale}.json`)).default;
|
||||
const mergedMessages = merge(englishMessages, messages);
|
||||
|
||||
return createTranslator({
|
||||
messages: mergedMessages,
|
||||
locale,
|
||||
});
|
||||
};
|
3
src/lib/safe-action.ts
Normal file
3
src/lib/safe-action.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { createSafeActionClient } from 'next-safe-action';
|
||||
|
||||
export const action = createSafeActionClient();
|
@ -1,13 +0,0 @@
|
||||
import { getMessagesPageProps } from '@/utils/page-helpers';
|
||||
import merge from 'lodash.merge';
|
||||
import { GetServerSideProps } from 'next';
|
||||
|
||||
export { LoginPage as default } from '../client/modules/Auth/pages/LoginPage';
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||
const messagesProps = await getMessagesPageProps(ctx);
|
||||
|
||||
return merge(messagesProps, {
|
||||
props: {},
|
||||
});
|
||||
};
|
@ -1,5 +1,3 @@
|
||||
import { setCookie } from 'cookies-next';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { v4 } from 'uuid';
|
||||
import { cookies } from 'next/headers';
|
||||
import { TipiCache } from '../core/TipiCache/TipiCache';
|
||||
@ -13,10 +11,11 @@ export const generateSessionId = (prefix: string) => {
|
||||
return `${prefix}-${v4()}`;
|
||||
};
|
||||
|
||||
export const setSession = async (sessionId: string, userId: string, req: NextApiRequest, res: NextApiResponse) => {
|
||||
export const setSession = async (sessionId: string, userId: string) => {
|
||||
const cache = new TipiCache('setSession');
|
||||
|
||||
setCookie(COOKIE_NAME, sessionId, { req, res, maxAge: COOKIE_MAX_AGE, httpOnly: true, secure: false, sameSite: false });
|
||||
const cookieStore = cookies();
|
||||
cookieStore.set(COOKIE_NAME, sessionId, { maxAge: COOKIE_MAX_AGE, httpOnly: true, secure: false, sameSite: false });
|
||||
|
||||
const sessionKey = `session:${sessionId}`;
|
||||
|
||||
|
@ -6,11 +6,7 @@ import { db } from '../../db';
|
||||
const AuthService = new AuthServiceClass(db);
|
||||
|
||||
export const authRouter = router({
|
||||
login: publicProcedure.input(z.object({ username: z.string(), password: z.string() })).mutation(async ({ input, ctx }) => AuthService.login({ ...input }, ctx.req, ctx.res)),
|
||||
logout: protectedProcedure.mutation(async ({ ctx }) => AuthService.logout(ctx.sessionId)),
|
||||
register: publicProcedure
|
||||
.input(z.object({ username: z.string(), password: z.string(), locale: z.string() }))
|
||||
.mutation(async ({ input, ctx }) => AuthService.register({ ...input }, ctx.req, ctx.res)),
|
||||
register: publicProcedure.input(z.object({ username: z.string(), password: z.string(), locale: z.string() })).mutation(async ({ input }) => AuthService.register({ ...input })),
|
||||
me: publicProcedure.query(async ({ ctx }) => AuthService.me(ctx.userId)),
|
||||
isConfigured: publicProcedure.query(async () => AuthService.isConfigured()),
|
||||
changeLocale: protectedProcedure.input(z.object({ locale: z.string() })).mutation(async ({ input, ctx }) => AuthService.changeLocale({ userId: Number(ctx.userId), locale: input.locale })),
|
||||
@ -22,10 +18,7 @@ export const authRouter = router({
|
||||
.input(z.object({ currentPassword: z.string(), newPassword: z.string() }))
|
||||
.mutation(({ input, ctx }) => AuthService.changePassword({ userId: Number(ctx.userId), ...input })),
|
||||
// Totp
|
||||
verifyTotp: publicProcedure.input(z.object({ totpSessionId: z.string(), totpCode: z.string() })).mutation(({ input, ctx }) => AuthService.verifyTotp(input, ctx.req, ctx.res)),
|
||||
getTotpUri: protectedProcedure.input(z.object({ password: z.string() })).mutation(({ input, ctx }) => AuthService.getTotpUri({ userId: Number(ctx.userId), password: input.password })),
|
||||
setupTotp: protectedProcedure.input(z.object({ totpCode: z.string() })).mutation(({ input, ctx }) => AuthService.setupTotp({ userId: Number(ctx.userId), totpCode: input.totpCode })),
|
||||
disableTotp: protectedProcedure.input(z.object({ password: z.string() })).mutation(({ input, ctx }) => AuthService.disableTotp({ userId: Number(ctx.userId), password: input.password })),
|
||||
});
|
||||
|
||||
export type AuthRouter = typeof authRouter;
|
||||
|
@ -7,7 +7,6 @@ import { TranslatedError } from '@/server/utils/errors';
|
||||
import { Locales, getLocaleFromString } from '@/shared/internationalization/locales';
|
||||
import { generateSessionId, setSession } from '@/server/common/session.helpers';
|
||||
import { Database } from '@/server/db';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getConfig } from '../../core/TipiConfig';
|
||||
import { TipiCache } from '../../core/TipiCache';
|
||||
import { fileExists, unlinkFile } from '../../common/fs.helpers';
|
||||
@ -30,10 +29,8 @@ export class AuthServiceClass {
|
||||
* Authenticate user with given username and password
|
||||
*
|
||||
* @param {UsernamePasswordInput} input - An object containing the user's username and password
|
||||
* @param {NextApiRequest} req - The Next.js request object
|
||||
* @param {NextApiResponse} res - The Next.js response object
|
||||
*/
|
||||
public login = async (input: UsernamePasswordInput, req: NextApiRequest, res: NextApiResponse) => {
|
||||
public login = async (input: UsernamePasswordInput) => {
|
||||
const { password, username } = input;
|
||||
const user = await this.queries.getUserByUsername(username);
|
||||
|
||||
@ -56,9 +53,9 @@ export class AuthServiceClass {
|
||||
}
|
||||
|
||||
const sessionId = uuidv4();
|
||||
await setSession(sessionId, user.id.toString(), req, res);
|
||||
await setSession(sessionId, user.id.toString());
|
||||
|
||||
return {};
|
||||
return { sessionId };
|
||||
};
|
||||
|
||||
/**
|
||||
@ -67,10 +64,8 @@ export class AuthServiceClass {
|
||||
* @param {object} params - An object containing the TOTP session ID and the TOTP code
|
||||
* @param {string} params.totpSessionId - The TOTP session ID
|
||||
* @param {string} params.totpCode - The TOTP code
|
||||
* @param {NextApiRequest} req - The Next.js request object
|
||||
* @param {NextApiResponse} res - The Next.js response object
|
||||
*/
|
||||
public verifyTotp = async (params: { totpSessionId: string; totpCode: string }, req: NextApiRequest, res: NextApiResponse) => {
|
||||
public verifyTotp = async (params: { totpSessionId: string; totpCode: string }) => {
|
||||
const { totpSessionId, totpCode } = params;
|
||||
const cache = new TipiCache('verifyTotp');
|
||||
const userId = await cache.get(totpSessionId);
|
||||
@ -98,7 +93,7 @@ export class AuthServiceClass {
|
||||
}
|
||||
|
||||
const sessionId = uuidv4();
|
||||
await setSession(sessionId, user.id.toString(), req, res);
|
||||
await setSession(sessionId, user.id.toString());
|
||||
|
||||
return true;
|
||||
};
|
||||
@ -203,10 +198,8 @@ export class AuthServiceClass {
|
||||
* Creates a new user with the provided email and password and returns a session token
|
||||
*
|
||||
* @param {UsernamePasswordInput} input - An object containing the email and password fields
|
||||
* @param {NextApiRequest} req - The Next.js request object
|
||||
* @param {NextApiResponse} res - The Next.js response object
|
||||
*/
|
||||
public register = async (input: UsernamePasswordInput, req: NextApiRequest, res: NextApiResponse) => {
|
||||
public register = async (input: UsernamePasswordInput) => {
|
||||
const operators = await this.queries.getOperators();
|
||||
|
||||
if (operators.length > 0) {
|
||||
@ -239,7 +232,7 @@ export class AuthServiceClass {
|
||||
}
|
||||
|
||||
const sessionId = uuidv4();
|
||||
await setSession(sessionId, newUser.id.toString(), req, res);
|
||||
await setSession(sessionId, newUser.id.toString());
|
||||
|
||||
return true;
|
||||
};
|
||||
|
16
src/utils/getCurrentLocale.ts
Normal file
16
src/utils/getCurrentLocale.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { getLocaleFromString } from '@/shared/internationalization/locales';
|
||||
import { cookies, headers } from 'next/headers';
|
||||
|
||||
/**
|
||||
* Get current locale from cookie or browser
|
||||
* @returns {string} current locale
|
||||
*/
|
||||
export const getCurrentLocale = () => {
|
||||
const cookieStore = cookies();
|
||||
const cookieLocale = cookieStore.get('tipi-locale');
|
||||
|
||||
const headersList = headers();
|
||||
const browserLocale = headersList.get('accept-language');
|
||||
|
||||
return getLocaleFromString(String(cookieLocale?.value || browserLocale || 'en'));
|
||||
};
|
@ -18,6 +18,12 @@
|
||||
"@/shared/*": [
|
||||
"./src/shared/*"
|
||||
],
|
||||
"@/lib/*": [
|
||||
"./src/lib/*"
|
||||
],
|
||||
"@/actions/*": [
|
||||
"./src/app/actions/*"
|
||||
],
|
||||
"@/tests/*": [
|
||||
"./tests/*"
|
||||
]
|
||||
|
Loading…
Reference in New Issue
Block a user