mirror of
https://github.com/diced/zipline.git
synced 2025-12-12 15:50:11 -08:00
feat: 2fa
This commit is contained in:
@@ -46,6 +46,8 @@
|
||||
"ms": "^2.1.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"next": "^13.4.7",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
@@ -64,6 +66,7 @@
|
||||
"@types/katex": "^0.16.0",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/qrcode": "^1.5.1",
|
||||
"@types/react": "^18.2.7",
|
||||
"@types/react-dom": "^18.2.4",
|
||||
"@types/signale": "^1.4.4",
|
||||
|
||||
@@ -27,6 +27,8 @@ model User {
|
||||
role Role @default(USER)
|
||||
view Json @default("{}")
|
||||
|
||||
totpSecret String?
|
||||
|
||||
files File[]
|
||||
urls Url[]
|
||||
folders Folder[]
|
||||
|
||||
@@ -276,6 +276,7 @@ export default function GeneratorButton({
|
||||
onClick={() => generators[name as keyof typeof generators](token!, generatorType as any, options)}
|
||||
fullWidth
|
||||
leftIcon={<IconDownload size='1rem' />}
|
||||
size='sm'
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useConfig } from '@/components/ConfigProvider';
|
||||
import { Group, SimpleGrid, Title } from '@mantine/core';
|
||||
import SettingsAvatar from './parts/SettingsAvatar';
|
||||
import SettingsDashboard from './parts/SettingsDashboard';
|
||||
import SettingsUser from './parts/SettingsUser';
|
||||
import SettingsFileView from './parts/SettingsFileView';
|
||||
import SettingsOAuth from './parts/SettingsOAuth';
|
||||
import SettingsGenerators from './parts/SettingsGenerators';
|
||||
import { useConfig } from '@/components/ConfigProvider';
|
||||
import SettingsMfa from './parts/SettingsMfa';
|
||||
import SettingsOAuth from './parts/SettingsOAuth';
|
||||
import SettingsUser from './parts/SettingsUser';
|
||||
|
||||
export default function DashboardSettings() {
|
||||
const config = useConfig();
|
||||
@@ -26,6 +27,7 @@ export default function DashboardSettings() {
|
||||
<SettingsFileView />
|
||||
|
||||
{config.features.oauthRegistration && <SettingsOAuth />}
|
||||
{config.mfa.totp.enabled && <SettingsMfa />}
|
||||
|
||||
<SettingsGenerators />
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { readToDataURL } from '@/lib/base64';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import useAvatar from '@/lib/hooks/useAvatar';
|
||||
import { readToDataURL } from '@/lib/base64';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import { Avatar, Button, Card, FileInput, Group, Paper, Stack, Text, Title } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconChevronDown, IconPhoto, IconPhotoCancel, IconPhotoUp, IconSettingsFilled } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
export default function SettingsAvatar() {
|
||||
const router = useRouter();
|
||||
const user = useUserStore((state) => state.user);
|
||||
|
||||
const { avatar: currentAvatar, mutate } = useAvatar();
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
ColorSwatch,
|
||||
DEFAULT_THEME,
|
||||
Group,
|
||||
MantineProvider,
|
||||
MantineThemeOverride,
|
||||
NumberInput,
|
||||
Paper,
|
||||
@@ -14,14 +13,13 @@ import {
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
Title,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconFile,
|
||||
IconMoonFilled,
|
||||
IconPaintFilled,
|
||||
IconPercentage,
|
||||
IconSunFilled,
|
||||
IconSunFilled
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
function ThemeSelectItem({ value, label, ...others }: { value: string; label: string }) {
|
||||
|
||||
@@ -2,15 +2,11 @@ import { Response } from '@/lib/api/response';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
ColorInput,
|
||||
CopyButton,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
ScrollArea,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
@@ -18,13 +14,11 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Title,
|
||||
Tooltip,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { hasLength, useForm } from '@mantine/form';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconCheck, IconCopy, IconFileX, IconUserCancel } from '@tabler/icons-react';
|
||||
import { forwardRef, useEffect, useState } from 'react';
|
||||
import { IconCheck, IconFileX } from '@tabler/icons-react';
|
||||
import { mutate } from 'swr';
|
||||
|
||||
export default function SettingsFileView() {
|
||||
|
||||
221
src/components/pages/settings/parts/SettingsMfa.tsx
Normal file
221
src/components/pages/settings/parts/SettingsMfa.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { User } from '@/lib/db/models/user';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import {
|
||||
Anchor,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Image,
|
||||
LoadingOverlay,
|
||||
Modal,
|
||||
Paper,
|
||||
PinInput,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconShieldLockFilled } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
|
||||
export default function SettingsMfa() {
|
||||
const [user, setUser] = useUserStore((state) => [state.user, state.setUser]);
|
||||
|
||||
const [totpOpen, setTotpOpen] = useState(false);
|
||||
const {
|
||||
data: twoData,
|
||||
error: twoError,
|
||||
isLoading: twoLoading,
|
||||
} = useSWR<Extract<Response['/api/user/mfa/totp'], { secret: string; qrcode: string }>>(
|
||||
totpOpen && !user?.totpSecret ? '/api/user/mfa/totp' : null,
|
||||
null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
revalidateIfStale: false,
|
||||
}
|
||||
);
|
||||
|
||||
const [pinDisabled, setPinDisabled] = useState(false);
|
||||
const [pinError, setPinError] = useState('');
|
||||
|
||||
const enable2fa = async (pin: string) => {
|
||||
if (pin.length !== 6) return setPinError('Invalid pin');
|
||||
|
||||
const { data, error } = await fetchApi<Extract<Response['/api/user/mfa/totp'], User>>(
|
||||
'/api/user/mfa/totp',
|
||||
'POST',
|
||||
{
|
||||
code: pin,
|
||||
secret: twoData!.secret,
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
setPinError(error.message!);
|
||||
setPinDisabled(false);
|
||||
} else {
|
||||
setTotpOpen(false);
|
||||
setPinDisabled(false);
|
||||
mutate('/api/user');
|
||||
setUser(data);
|
||||
|
||||
notifications.show({
|
||||
title: '2FA Enabled',
|
||||
message: 'You have successfully enabled 2FA on your account.',
|
||||
color: 'green',
|
||||
icon: <IconShieldLockFilled size='1rem' />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const disable2fa = async (pin: string) => {
|
||||
if (pin.length !== 6) return setPinError('Invalid pin');
|
||||
|
||||
const { data, error } = await fetchApi<Extract<Response['/api/user/mfa/totp'], User>>(
|
||||
'/api/user/mfa/totp',
|
||||
'DELETE',
|
||||
{
|
||||
code: pin,
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
setPinError(error.message!);
|
||||
setPinDisabled(false);
|
||||
} else {
|
||||
setTotpOpen(false);
|
||||
setPinDisabled(false);
|
||||
mutate('/api/user');
|
||||
setUser(data);
|
||||
|
||||
notifications.show({
|
||||
title: '2FA Disabled',
|
||||
message: 'You have successfully disabled 2FA on your account.',
|
||||
color: 'green',
|
||||
icon: <IconShieldLockFilled size='1rem' />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePinChange = (value: string) => {
|
||||
if (value.length === 6) {
|
||||
setPinDisabled(true);
|
||||
user?.totpSecret ? disable2fa(value) : enable2fa(value);
|
||||
} else {
|
||||
setPinError('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal title={<Title>Enable 2FA</Title>} opened={totpOpen} onClose={() => setTotpOpen(false)}>
|
||||
<Stack spacing='sm'>
|
||||
{user?.totpSecret ? (
|
||||
<Text size='sm' color='dimmed'>
|
||||
Enter the 6-digit code from your authenticator app below to confirm disabling 2FA.
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text size='sm' color='dimmed'>
|
||||
<b>Step 1</b> Open/download an authenticator that supports qrcode scanning or manual code
|
||||
entry. Popular options include{' '}
|
||||
<Anchor component={Link} href='https://authy.com/' target='_blank'>
|
||||
Authy
|
||||
</Anchor>
|
||||
,{' '}
|
||||
<Anchor
|
||||
component={Link}
|
||||
href='https://support.google.com/accounts/answer/1066447'
|
||||
target='_blank'
|
||||
>
|
||||
Google Authenticator
|
||||
</Anchor>
|
||||
, and{' '}
|
||||
<Anchor
|
||||
component={Link}
|
||||
href='https://www.microsoft.com/en-us/security/mobile-authenticator-app'
|
||||
target='_blank'
|
||||
>
|
||||
Microsoft Authenticator
|
||||
</Anchor>
|
||||
.
|
||||
</Text>
|
||||
|
||||
<Text size='sm' color='dimmed'>
|
||||
<b>Step 2</b> Scan the QR code below with your authenticator app to enable 2FA.
|
||||
</Text>
|
||||
|
||||
<Box pos='relative'>
|
||||
{twoLoading && !twoError ? (
|
||||
<LoadingOverlay visible />
|
||||
) : (
|
||||
<Center>
|
||||
<Image
|
||||
width={180}
|
||||
height={180}
|
||||
src={twoData?.qrcode}
|
||||
alt={'qr code ' + twoData?.secret ?? ''}
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Text size='sm' color='dimmed'>
|
||||
If you can't scan the QR code, you can manually enter the following code into your
|
||||
authenticator app: <br /> {twoData?.secret ?? ''}
|
||||
</Text>
|
||||
|
||||
<Text size='sm' color='dimmed'>
|
||||
<b>Step 3</b> Enter the 6-digit code from your authenticator app below to confirm 2FA setup.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Center>
|
||||
<PinInput
|
||||
data-autofocus
|
||||
length={6}
|
||||
oneTimeCode
|
||||
type='number'
|
||||
placeholder=''
|
||||
onChange={handlePinChange}
|
||||
autoFocus={true}
|
||||
error={!!pinError}
|
||||
disabled={pinDisabled}
|
||||
size='xl'
|
||||
/>
|
||||
</Center>
|
||||
{pinError && (
|
||||
<Text align='center' size='sm' color='red' mt={0}>
|
||||
{pinError}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Paper withBorder p='sm'>
|
||||
<Title order={2}>Multi-Factor Authentication</Title>
|
||||
<Text size='sm' color='dimmed' mt={3}>
|
||||
Setup 2FA to protect your account with an additional layer of security.
|
||||
</Text>
|
||||
|
||||
<Group mt='xs'>
|
||||
<Button
|
||||
size='sm'
|
||||
leftIcon={<IconShieldLockFilled size={24} />}
|
||||
color={user?.totpSecret ? 'red' : 'blue'}
|
||||
onClick={() => setTotpOpen(true)}
|
||||
>
|
||||
{user?.totpSecret ? 'Disable 2FA' : 'Enable 2FA'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,8 @@ import { useConfig } from '@/components/ConfigProvider';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { findProvider } from '@/lib/oauth/providerUtil';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import { Button, Group, Paper, Stack, Switch, Text, Title } from '@mantine/core';
|
||||
import { Button, Group, Paper, Text, Title } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import type { OAuthProviderType } from '@prisma/client';
|
||||
import {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ApiUserFilesIdPasswordResponse } from '@/pages/api/user/files/[id]/pass
|
||||
import { ApiUserFilesTransactionResponse } from '@/pages/api/user/files/transaction';
|
||||
import { ApiUserFoldersResponse } from '@/pages/api/user/folders';
|
||||
import { ApiUserFoldersIdResponse } from '@/pages/api/user/folders/[id]';
|
||||
import { ApiUserMfaTotpResponse } from '@/pages/api/user/mfa/totp';
|
||||
import { ApiUserRecentResponse } from '@/pages/api/user/recent';
|
||||
import { ApiUserStatsResponse } from '@/pages/api/user/stats';
|
||||
import { ApiUserTokenResponse } from '@/pages/api/user/token';
|
||||
@@ -30,6 +31,7 @@ export type Response = {
|
||||
'/api/auth/oauth': ApiAuthOauthResponse;
|
||||
'/api/auth/login': ApiLoginResponse;
|
||||
'/api/auth/logout': ApiLogoutResponse;
|
||||
'/api/user/mfa/totp': ApiUserMfaTotpResponse;
|
||||
'/api/user/folders/[id]': ApiUserFoldersIdResponse;
|
||||
'/api/user/folders': ApiUserFoldersResponse;
|
||||
'/api/user/files/[id]/password': ApiUserFilesIdPasswordResponse;
|
||||
|
||||
@@ -63,6 +63,9 @@ export const PROP_TO_ENV: Record<string, string> = {
|
||||
'website.theme.dark': 'WEBSITE_THEME_DARK',
|
||||
'website.theme.light': 'WEBSITE_THEME_LIGHT',
|
||||
|
||||
'mfa.totp.enabled': 'MFA_TOTP_ENABLED',
|
||||
'mfa.totp.issuer': 'MFA_TOTP_ISSUER',
|
||||
|
||||
'oauth.bypassLocalLogin': 'OAUTH_BYPASS_LOCAL_LOGIN',
|
||||
'oauth.loginOnly': 'OAUTH_LOGIN_ONLY',
|
||||
'oauth.discord.clientId': 'OAUTH_DISCORD_CLIENT_ID',
|
||||
@@ -134,6 +137,9 @@ export function readEnv() {
|
||||
env(PROP_TO_ENV['website.theme.dark'], 'website.theme.dark', 'string'),
|
||||
env(PROP_TO_ENV['website.theme.light'], 'website.theme.light', 'string'),
|
||||
|
||||
env(PROP_TO_ENV['mfa.totp.enabled'], 'mfa.totp.enabled', 'boolean'),
|
||||
env(PROP_TO_ENV['mfa.totp.issuer'], 'mfa.totp.issuer', 'string'),
|
||||
|
||||
env(PROP_TO_ENV['oauth.bypassLocalLogin'], 'oauth.bypassLocalLogin', 'boolean'),
|
||||
env(PROP_TO_ENV['oauth.loginOnly'], 'oauth.loginOnly', 'boolean'),
|
||||
env(PROP_TO_ENV['oauth.discord.clientId'], 'oauth.discord.clientId', 'string'),
|
||||
@@ -205,6 +211,12 @@ export function readEnv() {
|
||||
light: undefined,
|
||||
},
|
||||
},
|
||||
mfa: {
|
||||
totp: {
|
||||
enabled: undefined,
|
||||
issuer: undefined,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
bypassLocalLogin: undefined,
|
||||
loginOnly: undefined,
|
||||
|
||||
@@ -132,6 +132,13 @@ export const schema = z.object({
|
||||
light: z.string().default('builtin:light_gray'),
|
||||
}),
|
||||
}),
|
||||
mfa: z.object({
|
||||
totp: z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
issuer: z.string().default('Zipline'),
|
||||
}),
|
||||
// TODO: passkeys
|
||||
}),
|
||||
oauth: z.object({
|
||||
bypassLocalLogin: z.boolean().default(false),
|
||||
loginOnly: z.boolean().default(false),
|
||||
|
||||
@@ -11,6 +11,8 @@ export type User = {
|
||||
|
||||
oauthProviders: OAuthProvider[];
|
||||
|
||||
totpSecret?: string | null;
|
||||
|
||||
avatar?: string | null;
|
||||
password?: string | null;
|
||||
token?: string | null;
|
||||
@@ -24,6 +26,7 @@ export const userSelect = {
|
||||
role: true,
|
||||
view: true,
|
||||
oauthProviders: true,
|
||||
totpSecret: true,
|
||||
};
|
||||
|
||||
export type UserViewSettings = z.infer<typeof userViewSchema>;
|
||||
|
||||
24
src/lib/totp.ts
Normal file
24
src/lib/totp.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { authenticator } from 'otplib';
|
||||
import { toDataURL } from 'qrcode';
|
||||
|
||||
export function generateKey() {
|
||||
return authenticator.generateSecret(16);
|
||||
}
|
||||
|
||||
export function verifyTotpCode(code: string, secret: string) {
|
||||
return authenticator.check(code, secret);
|
||||
}
|
||||
|
||||
export function totpQrcode({
|
||||
issuer,
|
||||
username,
|
||||
secret,
|
||||
}: {
|
||||
issuer?: string;
|
||||
username: string;
|
||||
secret: string;
|
||||
}) {
|
||||
return toDataURL(authenticator.keyuri(username, issuer ?? 'Zipline', secret), {
|
||||
width: 180,
|
||||
});
|
||||
}
|
||||
@@ -5,19 +5,22 @@ import { loginToken } from '@/lib/login';
|
||||
import { combine } from '@/lib/middleware/combine';
|
||||
import { method } from '@/lib/middleware/method';
|
||||
import { NextApiReq, NextApiRes } from '@/lib/response';
|
||||
import { verifyTotpCode } from '@/lib/totp';
|
||||
|
||||
export type ApiLoginResponse = {
|
||||
user: User;
|
||||
token: string;
|
||||
user?: User;
|
||||
token?: string;
|
||||
totp?: true;
|
||||
};
|
||||
|
||||
type Body = {
|
||||
username: string;
|
||||
password: string;
|
||||
code?: string;
|
||||
};
|
||||
|
||||
async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiLoginResponse>) {
|
||||
const { username, password } = req.body;
|
||||
const { username, password, code } = req.body;
|
||||
|
||||
if (!username) return res.badRequest('Username is required');
|
||||
if (!password) return res.badRequest('Password is required');
|
||||
@@ -38,6 +41,16 @@ async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiLoginResponse>)
|
||||
const valid = await verifyPassword(password, user.password);
|
||||
if (!valid) return res.badRequest('Invalid password', { password: true });
|
||||
|
||||
if (user.totpSecret && code) {
|
||||
const valid = verifyTotpCode(code, user.totpSecret);
|
||||
if (!valid) return res.badRequest('Invalid code', { code: true });
|
||||
}
|
||||
|
||||
if (user.totpSecret && !code)
|
||||
return res.ok({
|
||||
totp: true,
|
||||
});
|
||||
|
||||
const token = loginToken(res, user);
|
||||
|
||||
delete (user as any).token;
|
||||
|
||||
75
src/pages/api/user/mfa/totp.ts
Normal file
75
src/pages/api/user/mfa/totp.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { config } from '@/lib/config';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { User, userSelect } from '@/lib/db/models/user';
|
||||
import { combine } from '@/lib/middleware/combine';
|
||||
import { method } from '@/lib/middleware/method';
|
||||
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
|
||||
import { NextApiReq, NextApiRes } from '@/lib/response';
|
||||
import { generateKey, totpQrcode, verifyTotpCode } from '@/lib/totp';
|
||||
|
||||
export type ApiUserMfaTotpResponse = User | { secret: string } | { secret: string; qrcode: string };
|
||||
|
||||
type Body = {
|
||||
code?: string;
|
||||
secret?: string;
|
||||
};
|
||||
|
||||
export async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiUserMfaTotpResponse>) {
|
||||
if (!config.mfa.totp.enabled) return res.badRequest('TOTP is disabled');
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
if (!req.user.totpSecret) return res.badRequest("You don't have TOTP enabled");
|
||||
|
||||
const { code } = req.body;
|
||||
if (!code) return res.badRequest('Missing code');
|
||||
if (code.length !== 6) return res.badRequest('Invalid code');
|
||||
|
||||
const valid = verifyTotpCode(code, req.user.totpSecret);
|
||||
if (!valid) return res.badRequest('Invalid code');
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: { totpSecret: null },
|
||||
select: userSelect,
|
||||
});
|
||||
|
||||
return res.json(user);
|
||||
} else if (req.method === 'POST') {
|
||||
const { code, secret } = req.body;
|
||||
if (!code) return res.badRequest('Missing code');
|
||||
if (code.length !== 6) return res.badRequest('Invalid code');
|
||||
|
||||
if (!secret) return res.badRequest('Missing secret');
|
||||
|
||||
const valid = verifyTotpCode(code, secret);
|
||||
if (!valid) return res.badRequest('Invalid code');
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: { totpSecret: secret },
|
||||
select: userSelect,
|
||||
});
|
||||
|
||||
return res.json(user);
|
||||
}
|
||||
|
||||
if (!req.user.totpSecret) {
|
||||
const secret = generateKey();
|
||||
const qrcode = await totpQrcode({
|
||||
issuer: config.mfa.totp.issuer,
|
||||
username: req.user.username,
|
||||
secret,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
secret,
|
||||
qrcode,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
secret: req.user.totpSecret,
|
||||
});
|
||||
}
|
||||
|
||||
export default combine([method(['GET', 'POST', 'DELETE']), ziplineAuth()], handler);
|
||||
@@ -115,7 +115,10 @@ export async function handler(req: NextApiReq<Body, Query>, res: NextApiRes<ApiU
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
select: userSelect,
|
||||
select: {
|
||||
...userSelect,
|
||||
totpSecret: false,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`${req.user.username} deleted another user`, {
|
||||
|
||||
@@ -59,7 +59,10 @@ export async function handler(req: NextApiReq<Body, Query>, res: NextApiRes<ApiU
|
||||
avatar: avatar64 ?? null,
|
||||
token: createToken(),
|
||||
},
|
||||
select: userSelect,
|
||||
select: {
|
||||
...userSelect,
|
||||
totpSecret: false,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`${req.user.username} created a new user`, {
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { config } from '@/lib/config';
|
||||
import { SafeConfig } from '@/lib/config/safe';
|
||||
import { getZipline } from '@/lib/db/models/zipline';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { withSafeConfig } from '@/lib/middleware/next/withSafeConfig';
|
||||
import { eitherTrue, isTruthy } from '@/lib/primitive';
|
||||
import { eitherTrue } from '@/lib/primitive';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Modal,
|
||||
PasswordInput,
|
||||
PinInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
@@ -22,11 +24,13 @@ import {
|
||||
IconBrandGithubFilled,
|
||||
IconBrandGoogle,
|
||||
IconCircleKeyFilled,
|
||||
IconShieldQuestion,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { InferGetServerSidePropsType } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
export default function Login({ config }: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||
@@ -44,6 +48,11 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
|
||||
Object.values(config.oauthEnabled).filter((x) => x === true).length === 1 &&
|
||||
router.query.local !== 'true';
|
||||
|
||||
const [totpOpen, setTotpOpen] = useState(false);
|
||||
const [pinDisabled, setPinDisabled] = useState(false);
|
||||
const [pinError, setPinError] = useState('');
|
||||
const [pin, setPin] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.user) {
|
||||
router.push('/dashboard');
|
||||
@@ -61,22 +70,42 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: typeof form.values) => {
|
||||
const onSubmit = async (values: typeof form.values, code: string | undefined = undefined) => {
|
||||
setPinDisabled(true);
|
||||
setPinError('');
|
||||
|
||||
const { username, password } = values;
|
||||
|
||||
const { data, error } = await fetchApi<Response['/api/auth/login']>('/api/auth/login', 'POST', {
|
||||
username,
|
||||
password,
|
||||
code,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
if (error.username) form.setFieldError('username', 'Invalid username');
|
||||
else if (error.password) form.setFieldError('password', 'Invalid password');
|
||||
else if (error.code) setPinError(error.message!);
|
||||
setPinDisabled(false);
|
||||
} else {
|
||||
if (data!.totp) {
|
||||
setTotpOpen(true);
|
||||
setPinDisabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
mutate(data as Response['/api/user']);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePinChange = (value: string) => {
|
||||
setPin(value);
|
||||
|
||||
if (value.length === 6) {
|
||||
onSubmit(form.values, value);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (willRedirect) {
|
||||
const provider = Object.keys(config.oauthEnabled).find(
|
||||
@@ -93,6 +122,50 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
|
||||
<>
|
||||
{willRedirect && !showLocalLogin && <LoadingOverlay visible />}
|
||||
|
||||
<Modal onClose={() => {}} title={<Title order={3}>Enter code</Title>} opened={totpOpen} withCloseButton={false}>
|
||||
<Center>
|
||||
<PinInput
|
||||
data-autofocus
|
||||
length={6}
|
||||
oneTimeCode
|
||||
type='number'
|
||||
placeholder=''
|
||||
onChange={handlePinChange}
|
||||
autoFocus={true}
|
||||
error={!!pinError}
|
||||
disabled={pinDisabled}
|
||||
size='xl'
|
||||
/>
|
||||
</Center>
|
||||
{pinError && (
|
||||
<Text align='center' size='sm' color='red' mt={0}>
|
||||
{pinError}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group mt='sm' grow>
|
||||
<Button
|
||||
leftIcon={<IconX size='1rem' />}
|
||||
color='red'
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
setTotpOpen(false);
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
Cancel login attempt
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<IconShieldQuestion size='1rem' />}
|
||||
loading={pinDisabled}
|
||||
type='submit'
|
||||
onClick={() => onSubmit(form.values, pin)}
|
||||
>
|
||||
Verify
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
|
||||
<Center
|
||||
h='100vh'
|
||||
sx={
|
||||
@@ -120,7 +193,7 @@ export default function Login({ config }: InferGetServerSidePropsType<typeof get
|
||||
|
||||
{showLocalLogin && (
|
||||
<>
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
||||
<Stack my='sm'>
|
||||
<TextInput
|
||||
size='lg'
|
||||
|
||||
202
yarn.lock
202
yarn.lock
@@ -3744,6 +3744,54 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@otplib/core@npm:^12.0.1":
|
||||
version: 12.0.1
|
||||
resolution: "@otplib/core@npm:12.0.1"
|
||||
checksum: b3c34bc20b31bc3f49cc0dc3c0eb070491c0101e8c1efa83cec48ca94158bd736aaca8187df667fc0c4a239d4ac52076bc44084bee04a50c80c3630caf77affa
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@otplib/plugin-crypto@npm:^12.0.1":
|
||||
version: 12.0.1
|
||||
resolution: "@otplib/plugin-crypto@npm:12.0.1"
|
||||
dependencies:
|
||||
"@otplib/core": ^12.0.1
|
||||
checksum: 6867c74ee8aca6c2db9670362cf51e44f3648602c39318bf537421242e33f0012a172acd43bbed9a21d706e535dc4c66aff965380673391e9fd74cf685b5b13a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@otplib/plugin-thirty-two@npm:^12.0.1":
|
||||
version: 12.0.1
|
||||
resolution: "@otplib/plugin-thirty-two@npm:12.0.1"
|
||||
dependencies:
|
||||
"@otplib/core": ^12.0.1
|
||||
thirty-two: ^1.0.2
|
||||
checksum: 920099e40d3e8c2941291c84c70064c2d86d0d1ed17230d650445d5463340e406bc413ddf2e40c374ddc4ee988ef1e3facacab9b5248b1ff361fd13df52bf88f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@otplib/preset-default@npm:^12.0.1":
|
||||
version: 12.0.1
|
||||
resolution: "@otplib/preset-default@npm:12.0.1"
|
||||
dependencies:
|
||||
"@otplib/core": ^12.0.1
|
||||
"@otplib/plugin-crypto": ^12.0.1
|
||||
"@otplib/plugin-thirty-two": ^12.0.1
|
||||
checksum: 8133231384f6277f77eb8e42ef83bc32a8b01059bef147d1c358d9e9bfd292e1c239f581fe008367a48489dd68952b7ac0948e6c41412fc06079da2c91b71d16
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@otplib/preset-v11@npm:^12.0.1":
|
||||
version: 12.0.1
|
||||
resolution: "@otplib/preset-v11@npm:12.0.1"
|
||||
dependencies:
|
||||
"@otplib/core": ^12.0.1
|
||||
"@otplib/plugin-crypto": ^12.0.1
|
||||
"@otplib/plugin-thirty-two": ^12.0.1
|
||||
checksum: 367cb09397e617c21ec748d54e920ab43f1c5dfba70cbfd88edf73aecca399cf0c09fefe32518f79c7ee8a06e7058d14b200da378cc7d46af3cac4e22a153e2f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@phc/format@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "@phc/format@npm:1.0.0"
|
||||
@@ -4649,6 +4697,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/qrcode@npm:^1.5.1":
|
||||
version: 1.5.1
|
||||
resolution: "@types/qrcode@npm:1.5.1"
|
||||
dependencies:
|
||||
"@types/node": "*"
|
||||
checksum: 0f780f31d983fa8d1bfbba1a130e8f7b07866d556807eccfb1d08d841c70644cdee8fc169ca752bdf17e99995e6155fcb6179079338ac8359294c781b95112b5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/qs@npm:*":
|
||||
version: 6.9.7
|
||||
resolution: "@types/qs@npm:6.9.7"
|
||||
@@ -5840,6 +5897,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"camelcase@npm:^5.0.0":
|
||||
version: 5.3.1
|
||||
resolution: "camelcase@npm:5.3.1"
|
||||
checksum: e6effce26b9404e3c0f301498184f243811c30dfe6d0b9051863bd8e4034d09c8c2923794f280d6827e5aa055f6c434115ff97864a16a963366fb35fd673024b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"caniuse-lite@npm:^1.0.30001406":
|
||||
version: 1.0.30001507
|
||||
resolution: "caniuse-lite@npm:1.0.30001507"
|
||||
@@ -6019,6 +6083,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"cliui@npm:^6.0.0":
|
||||
version: 6.0.0
|
||||
resolution: "cliui@npm:6.0.0"
|
||||
dependencies:
|
||||
string-width: ^4.2.0
|
||||
strip-ansi: ^6.0.0
|
||||
wrap-ansi: ^6.2.0
|
||||
checksum: 4fcfd26d292c9f00238117f39fc797608292ae36bac2168cfee4c85923817d0607fe21b3329a8621e01aedf512c99b7eaa60e363a671ffd378df6649fb48ae42
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"clone-response@npm:^1.0.2":
|
||||
version: 1.0.3
|
||||
resolution: "clone-response@npm:1.0.3"
|
||||
@@ -6438,6 +6513,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"decamelize@npm:^1.2.0":
|
||||
version: 1.2.0
|
||||
resolution: "decamelize@npm:1.2.0"
|
||||
checksum: ad8c51a7e7e0720c70ec2eeb1163b66da03e7616d7b98c9ef43cce2416395e84c1e9548dd94f5f6ffecfee9f8b94251fc57121a8b021f2ff2469b2bae247b8aa
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"decimal.js@npm:^10.4.3":
|
||||
version: 10.4.3
|
||||
resolution: "decimal.js@npm:10.4.3"
|
||||
@@ -6691,6 +6773,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"dijkstrajs@npm:^1.0.1":
|
||||
version: 1.0.3
|
||||
resolution: "dijkstrajs@npm:1.0.3"
|
||||
checksum: 82ff2c6633f235dd5e6bed04ec62cdfb1f327b4d7534557bd52f18991313f864ee50654543072fff4384a92b643ada4d5452f006b7098dbdfad6c8744a8c9e08
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"dir-glob@npm:^3.0.1":
|
||||
version: 3.0.1
|
||||
resolution: "dir-glob@npm:3.0.1"
|
||||
@@ -6875,6 +6964,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"encode-utf8@npm:^1.0.3":
|
||||
version: 1.0.3
|
||||
resolution: "encode-utf8@npm:1.0.3"
|
||||
checksum: 550224bf2a104b1d355458c8a82e9b4ea07f9fc78387bc3a49c151b940ad26473de8dc9e121eefc4e84561cb0b46de1e4cd2bc766f72ee145e9ea9541482817f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"encodeurl@npm:~1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "encodeurl@npm:1.0.2"
|
||||
@@ -8376,6 +8472,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"get-caller-file@npm:^2.0.1":
|
||||
version: 2.0.5
|
||||
resolution: "get-caller-file@npm:2.0.5"
|
||||
checksum: b9769a836d2a98c3ee734a88ba712e62703f1df31b94b784762c433c27a386dd6029ff55c2a920c392e33657d80191edbf18c61487e198844844516f843496b9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"get-intrinsic@npm:^1.0.2, get-intrinsic@npm:^1.1.1, get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.0":
|
||||
version: 1.2.1
|
||||
resolution: "get-intrinsic@npm:1.2.1"
|
||||
@@ -12052,6 +12155,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"otplib@npm:^12.0.1":
|
||||
version: 12.0.1
|
||||
resolution: "otplib@npm:12.0.1"
|
||||
dependencies:
|
||||
"@otplib/core": ^12.0.1
|
||||
"@otplib/preset-default": ^12.0.1
|
||||
"@otplib/preset-v11": ^12.0.1
|
||||
checksum: 4a1b91cf1b8e920b50ad4bac2ef2a89126630c62daf68e9b32ff15106b2551db905d3b979955cf5f8f114da0a8883cec3d636901d65e793c1745bb4174e2a572
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"outdent@npm:^0.8.0":
|
||||
version: 0.8.0
|
||||
resolution: "outdent@npm:0.8.0"
|
||||
@@ -12514,6 +12628,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"pngjs@npm:^5.0.0":
|
||||
version: 5.0.0
|
||||
resolution: "pngjs@npm:5.0.0"
|
||||
checksum: 04e912cc45fb9601564e2284efaf0c5d20d131d9b596244f8a6789fc6cdb6b18d2975a6bbf7a001858d7e159d5c5c5dd7b11592e97629b7137f7f5cef05904c8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"postcss-discard-duplicates@npm:^5.1.0":
|
||||
version: 5.1.0
|
||||
resolution: "postcss-discard-duplicates@npm:5.1.0"
|
||||
@@ -12885,6 +13006,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"qrcode@npm:^1.5.3":
|
||||
version: 1.5.3
|
||||
resolution: "qrcode@npm:1.5.3"
|
||||
dependencies:
|
||||
dijkstrajs: ^1.0.1
|
||||
encode-utf8: ^1.0.3
|
||||
pngjs: ^5.0.0
|
||||
yargs: ^15.3.1
|
||||
bin:
|
||||
qrcode: bin/qrcode
|
||||
checksum: 9a8a20a0a9cb1d15de8e7b3ffa214e8b6d2a8b07655f25bd1b1d77f4681488f84d7bae569870c0652872d829d5f8ac4922c27a6bd14c13f0e197bf07b28dead7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"qs@npm:6.11.0":
|
||||
version: 6.11.0
|
||||
resolution: "qs@npm:6.11.0"
|
||||
@@ -13409,6 +13544,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"require-directory@npm:^2.1.1":
|
||||
version: 2.1.1
|
||||
resolution: "require-directory@npm:2.1.1"
|
||||
checksum: fb47e70bf0001fdeabdc0429d431863e9475e7e43ea5f94ad86503d918423c1543361cc5166d713eaa7029dd7a3d34775af04764bebff99ef413111a5af18c80
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"require-like@npm:>= 0.1.1":
|
||||
version: 0.1.2
|
||||
resolution: "require-like@npm:0.1.2"
|
||||
@@ -13416,6 +13558,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"require-main-filename@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "require-main-filename@npm:2.0.0"
|
||||
checksum: e9e294695fea08b076457e9ddff854e81bffbe248ed34c1eec348b7abbd22a0d02e8d75506559e2265e96978f3c4720bd77a6dad84755de8162b357eb6c778c7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"requireindex@npm:^1.2.0":
|
||||
version: 1.2.0
|
||||
resolution: "requireindex@npm:1.2.0"
|
||||
@@ -14717,6 +14866,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"thirty-two@npm:^1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "thirty-two@npm:1.0.2"
|
||||
checksum: f6700b31d16ef942fdc0d14daed8a2f69ea8b60b0e85db8b83adf58d84bbeafe95a17d343ab55efaae571bb5148b62fc0ee12b04781323bf7af7d7e9693eec76
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"through2@npm:^2.0.3":
|
||||
version: 2.0.5
|
||||
resolution: "through2@npm:2.0.5"
|
||||
@@ -15704,6 +15860,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"which-module@npm:^2.0.0":
|
||||
version: 2.0.1
|
||||
resolution: "which-module@npm:2.0.1"
|
||||
checksum: 1967b7ce17a2485544a4fdd9063599f0f773959cca24176dbe8f405e55472d748b7c549cd7920ff6abb8f1ab7db0b0f1b36de1a21c57a8ff741f4f1e792c52be
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"which-typed-array@npm:^1.1.9":
|
||||
version: 1.1.9
|
||||
resolution: "which-typed-array@npm:1.1.9"
|
||||
@@ -15896,6 +16059,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"y18n@npm:^4.0.0":
|
||||
version: 4.0.3
|
||||
resolution: "y18n@npm:4.0.3"
|
||||
checksum: 014dfcd9b5f4105c3bb397c1c8c6429a9df004aa560964fb36732bfb999bfe83d45ae40aeda5b55d21b1ee53d8291580a32a756a443e064317953f08025b1aa4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"yallist@npm:^3.0.2":
|
||||
version: 3.1.1
|
||||
resolution: "yallist@npm:3.1.1"
|
||||
@@ -15924,6 +16094,35 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"yargs-parser@npm:^18.1.2":
|
||||
version: 18.1.3
|
||||
resolution: "yargs-parser@npm:18.1.3"
|
||||
dependencies:
|
||||
camelcase: ^5.0.0
|
||||
decamelize: ^1.2.0
|
||||
checksum: 60e8c7d1b85814594d3719300ecad4e6ae3796748b0926137bfec1f3042581b8646d67e83c6fc80a692ef08b8390f21ddcacb9464476c39bbdf52e34961dd4d9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"yargs@npm:^15.3.1":
|
||||
version: 15.4.1
|
||||
resolution: "yargs@npm:15.4.1"
|
||||
dependencies:
|
||||
cliui: ^6.0.0
|
||||
decamelize: ^1.2.0
|
||||
find-up: ^4.1.0
|
||||
get-caller-file: ^2.0.1
|
||||
require-directory: ^2.1.1
|
||||
require-main-filename: ^2.0.0
|
||||
set-blocking: ^2.0.0
|
||||
string-width: ^4.2.0
|
||||
which-module: ^2.0.0
|
||||
y18n: ^4.0.0
|
||||
yargs-parser: ^18.1.2
|
||||
checksum: 40b974f508d8aed28598087720e086ecd32a5fd3e945e95ea4457da04ee9bdb8bdd17fd91acff36dc5b7f0595a735929c514c40c402416bbb87c03f6fb782373
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"yocto-queue@npm:^0.1.0":
|
||||
version: 0.1.0
|
||||
resolution: "yocto-queue@npm:0.1.0"
|
||||
@@ -15968,6 +16167,7 @@ __metadata:
|
||||
"@types/katex": ^0.16.0
|
||||
"@types/multer": ^1.4.7
|
||||
"@types/node": ^20.3.1
|
||||
"@types/qrcode": ^1.5.1
|
||||
"@types/react": ^18.2.7
|
||||
"@types/react-dom": ^18.2.4
|
||||
"@types/signale": ^1.4.4
|
||||
@@ -15988,7 +16188,9 @@ __metadata:
|
||||
multer: ^1.4.5-lts.1
|
||||
next: ^13.4.7
|
||||
npm-run-all: ^4.1.5
|
||||
otplib: ^12.0.1
|
||||
prisma: ^5.0.0
|
||||
qrcode: ^1.5.3
|
||||
react: ^18.2.0
|
||||
react-dom: ^18.2.0
|
||||
react-markdown: ^8.0.7
|
||||
|
||||
Reference in New Issue
Block a user