feat: move security form to rsc

This commit is contained in:
Nicolas Meienberger 2023-10-01 19:02:04 +02:00 committed by Nicolas Meienberger
parent b3e1245da2
commit 1b434e7355
17 changed files with 201 additions and 77 deletions

View file

@ -1,17 +1,16 @@
import React from 'react'; import React from 'react';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { trpc } from '@/utils/trpc';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/router'; import { useRouter } from 'next/navigation';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import type { MessageKey } from '@/server/utils/errors'; import { useAction } from 'next-safe-action/hook';
import { changePasswordAction } from '@/actions/settings/change-password';
export const ChangePasswordForm = () => { export const ChangePasswordForm = () => {
const globalT = useTranslations();
const t = useTranslations('settings.security'); const t = useTranslations('settings.security');
const schema = z const schema = z
@ -29,16 +28,19 @@ export const ChangePasswordForm = () => {
}); });
} }
}); });
type FormValues = z.infer<typeof schema>; type FormValues = z.infer<typeof schema>;
const router = useRouter(); const router = useRouter();
const changePassword = trpc.auth.changePassword.useMutation({
onError: (e) => { const changePasswordMutation = useAction(changePasswordAction, {
toast.error(globalT(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables })); onSuccess: (data) => {
}, if (!data.success) {
onSuccess: () => { toast.error(data.failure.reason);
toast.success(t('password-change-success')); } else {
router.push('/'); toast.success(t('password-change-success'));
router.push('/');
}
}, },
}); });
@ -51,22 +53,22 @@ export const ChangePasswordForm = () => {
}); });
const onSubmit = (values: FormValues) => { const onSubmit = (values: FormValues) => {
changePassword.mutate(values); changePasswordMutation.execute(values);
}; };
return ( return (
<form onSubmit={handleSubmit(onSubmit)} className="mb-4 w-100 "> <form onSubmit={handleSubmit(onSubmit)} className="mb-4 w-100 ">
<Input disabled={changePassword.isLoading} {...register('currentPassword')} error={errors.currentPassword?.message} type="password" placeholder={t('form.current-password')} /> <Input disabled={changePasswordMutation.isExecuting} {...register('currentPassword')} error={errors.currentPassword?.message} type="password" placeholder={t('form.current-password')} />
<Input disabled={changePassword.isLoading} {...register('newPassword')} error={errors.newPassword?.message} className="mt-2" type="password" placeholder={t('form.new-password')} /> <Input disabled={changePasswordMutation.isExecuting} {...register('newPassword')} error={errors.newPassword?.message} className="mt-2" type="password" placeholder={t('form.new-password')} />
<Input <Input
disabled={changePassword.isLoading} disabled={changePasswordMutation.isExecuting}
{...register('newPasswordConfirm')} {...register('newPasswordConfirm')}
error={errors.newPasswordConfirm?.message} error={errors.newPasswordConfirm?.message}
className="mt-2" className="mt-2"
type="password" type="password"
placeholder={t('form.confirm-password')} placeholder={t('form.confirm-password')}
/> />
<Button disabled={changePassword.isLoading} className="mt-3" type="submit"> <Button disabled={changePasswordMutation.isExecuting} className="mt-3" type="submit">
{t('form.change-password')} {t('form.change-password')}
</Button> </Button>
</form> </form>

View file

@ -1,5 +1,4 @@
import React from 'react'; import React from 'react';
import { trpc } from '@/utils/trpc';
import { Switch } from '@/components/ui/Switch'; import { Switch } from '@/components/ui/Switch';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/Dialog'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
@ -9,10 +8,13 @@ import { OtpInput } from '@/components/ui/OtpInput';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { useDisclosure } from '@/client/hooks/useDisclosure'; import { useDisclosure } from '@/client/hooks/useDisclosure';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import type { MessageKey } from '@/server/utils/errors'; import { useAction } from 'next-safe-action/hook';
import { getTotpUriAction } from '@/actions/settings/get-totp-uri';
import { setupTotpAction } from '@/actions/settings/setup-totp-action';
import { disableTotpAction } from '@/actions/settings/disable-totp';
export const OtpForm = () => { export const OtpForm = (props: { totpEnabled: boolean }) => {
const globalT = useTranslations(); const { totpEnabled } = props;
const t = useTranslations('settings.security'); const t = useTranslations('settings.security');
const [password, setPassword] = React.useState(''); const [password, setPassword] = React.useState('');
const [key, setKey] = React.useState(''); const [key, setKey] = React.useState('');
@ -23,54 +25,53 @@ export const OtpForm = () => {
const setupOtpDisclosure = useDisclosure(); const setupOtpDisclosure = useDisclosure();
const disableOtpDisclosure = useDisclosure(); const disableOtpDisclosure = useDisclosure();
const ctx = trpc.useContext(); const getTotpUriMutation = useAction(getTotpUriAction, {
const me = trpc.auth.me.useQuery(); onExecute: () => {
const getTotpUri = trpc.auth.getTotpUri.useMutation({
onMutate: () => {
setupOtpDisclosure.close(); setupOtpDisclosure.close();
}, },
onError: (e) => {
setPassword('');
toast.error(globalT(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables }));
},
onSuccess: (data) => { onSuccess: (data) => {
setKey(data.key); if (!data.success) {
setUri(data.uri); setPassword('');
toast.error(data.failure.reason);
} else {
setKey(data.key);
setUri(data.uri);
}
}, },
}); });
const setupTotp = trpc.auth.setupTotp.useMutation({ const setupTotpMutation = useAction(setupTotpAction, {
onMutate: () => {}, onSuccess: (data) => {
onError: (e) => { if (!data.success) {
setTotpCode(''); setTotpCode('');
toast.error(globalT(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables })); toast.error(data.failure.reason);
}, } else {
onSuccess: () => { setTotpCode('');
setTotpCode(''); setKey('');
setKey(''); setUri('');
setUri(''); toast.success(t('2fa-enable-success'));
toast.success(t('2fa-enable-success')); // ctx.auth.me.invalidate();
ctx.auth.me.invalidate(); }
}, },
}); });
const disableTotp = trpc.auth.disableTotp.useMutation({ const disableTotpMutation = useAction(disableTotpAction, {
onMutate: () => { onExecute: () => {
disableOtpDisclosure.close(); disableOtpDisclosure.close();
}, },
onError: (e) => { onSuccess: (data) => {
setPassword(''); if (!data.success) {
toast.error(globalT(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables })); setPassword('');
}, toast.error(data.failure.reason);
onSuccess: () => { } else {
toast.success(t('2fa-disable-success')); toast.success(t('2fa-disable-success'));
ctx.auth.me.invalidate(); //ctx.auth.me.invalidate();
}
}, },
}); });
const renderSetupQr = () => { const renderSetupQr = () => {
if (!uri || me.data?.totpEnabled) return null; if (!uri || totpEnabled) return null;
return ( return (
<div className="mt-4"> <div className="mt-4">
@ -85,7 +86,7 @@ export const OtpForm = () => {
<div className="mb-4"> <div className="mb-4">
<p className="text-muted">{t('enter-2fa-code')}</p> <p className="text-muted">{t('enter-2fa-code')}</p>
<OtpInput value={totpCode} valueLength={6} onChange={(e) => setTotpCode(e)} /> <OtpInput value={totpCode} valueLength={6} onChange={(e) => setTotpCode(e)} />
<Button disabled={totpCode.trim().length < 6} onClick={() => setupTotp.mutate({ totpCode })} className="mt-3 btn-success"> <Button disabled={totpCode.trim().length < 6} onClick={() => setupTotpMutation.execute({ totpCode })} className="mt-3 btn-success">
{t('enable-2fa')} {t('enable-2fa')}
</Button> </Button>
</div> </div>
@ -103,8 +104,8 @@ export const OtpForm = () => {
return ( return (
<> <>
{!key && <Switch disabled={!me.isSuccess} onCheckedChange={handleTotp} checked={me.data?.totpEnabled} label={t('enable-2fa')} />} {!key && <Switch onCheckedChange={handleTotp} checked={totpEnabled} label={t('enable-2fa')} />}
{getTotpUri.isLoading && ( {getTotpUriMutation.isExecuting && (
<div className="progress w-50"> <div className="progress w-50">
<div className="progress-bar progress-bar-indeterminate bg-green" /> <div className="progress-bar progress-bar-indeterminate bg-green" />
</div> </div>
@ -119,12 +120,12 @@ export const OtpForm = () => {
<form <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
getTotpUri.mutate({ password }); getTotpUriMutation.execute({ password });
}} }}
> >
<p className="text-muted">{t('password-needed-hint')}</p> <p className="text-muted">{t('password-needed-hint')}</p>
<Input name="password" type="password" onChange={(e) => setPassword(e.target.value)} placeholder={t('form.password')} /> <Input name="password" type="password" onChange={(e) => setPassword(e.target.value)} placeholder={t('form.password')} />
<Button loading={getTotpUri.isLoading} type="submit" className="btn-success mt-3"> <Button loading={getTotpUriMutation.isExecuting} type="submit" className="btn-success mt-3">
{t('enable-2fa')} {t('enable-2fa')}
</Button> </Button>
</form> </form>
@ -140,12 +141,12 @@ export const OtpForm = () => {
<form <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
disableTotp.mutate({ password }); disableTotpMutation.execute({ password });
}} }}
> >
<p className="text-muted">{t('password-needed-hint')}</p> <p className="text-muted">{t('password-needed-hint')}</p>
<Input name="password" type="password" onChange={(e) => setPassword(e.target.value)} placeholder={t('form.password')} /> <Input name="password" type="password" onChange={(e) => setPassword(e.target.value)} placeholder={t('form.password')} />
<Button loading={disableTotp.isLoading} type="submit" className="btn-danger mt-3"> <Button loading={disableTotpMutation.isExecuting} type="submit" className="btn-danger mt-3">
{t('disable-2fa')} {t('disable-2fa')}
</Button> </Button>
</form> </form>

View file

@ -1,11 +1,15 @@
'use client';
import React from 'react'; import React from 'react';
import { IconLock, IconKey } from '@tabler/icons-react'; import { IconLock, IconKey } from '@tabler/icons-react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { OtpForm } from '../../components/OtpForm'; import { OtpForm } from '../OtpForm';
import { ChangePasswordForm } from '../../components/ChangePasswordForm'; import { ChangePasswordForm } from '../ChangePasswordForm';
export const SecurityContainer = () => { export const SecurityContainer = (props: { totpEnabled: boolean }) => {
const { totpEnabled } = props;
const t = useTranslations('settings.security'); const t = useTranslations('settings.security');
return ( return (
<div className="card-body"> <div className="card-body">
<div className="d-flex"> <div className="d-flex">
@ -23,7 +27,7 @@ export const SecurityContainer = () => {
<br /> <br />
{t('2fa-subtitle-2')} {t('2fa-subtitle-2')}
</p> </p>
<OtpForm /> <OtpForm totpEnabled={totpEnabled} />
</div> </div>
); );
}; };

View file

@ -8,6 +8,8 @@ import { getCurrentLocale } from 'src/utils/getCurrentLocale';
import { SettingsTabTriggers } from './components/SettingsTabTriggers'; import { SettingsTabTriggers } from './components/SettingsTabTriggers';
import { GeneralActions } from './components/GeneralActions'; import { GeneralActions } from './components/GeneralActions';
import { SettingsContainer } from './components/SettingsContainer'; import { SettingsContainer } from './components/SettingsContainer';
import { SecurityContainer } from './components/SecurityContainer';
import { getUserFromCookie } from '@/server/common/session.helpers';
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const translator = await getTranslatorFromCookie(); const translator = await getTranslatorFromCookie();
@ -23,6 +25,7 @@ export default async function SettingsPage({ searchParams }: { searchParams: { t
const version = await systemService.getVersion(); const version = await systemService.getVersion();
const settings = getSettings(); const settings = getSettings();
const locale = getCurrentLocale(); const locale = getCurrentLocale();
const user = await getUserFromCookie();
return ( return (
<div className="card d-flex"> <div className="card d-flex">
@ -34,7 +37,9 @@ export default async function SettingsPage({ searchParams }: { searchParams: { t
<TabsContent value="settings"> <TabsContent value="settings">
<SettingsContainer initialValues={settings} currentLocale={locale} /> <SettingsContainer initialValues={settings} currentLocale={locale} />
</TabsContent> </TabsContent>
<TabsContent value="security">{/* <SecurityContainer /> */}</TabsContent> <TabsContent value="security">
<SecurityContainer totpEnabled={Boolean(user?.totpEnabled)} />
</TabsContent>
</Tabs> </Tabs>
</div> </div>
); );

View file

@ -0,0 +1,31 @@
'use server';
import { z } from 'zod';
import { action } from '@/lib/safe-action';
import { getUserFromCookie } from '@/server/common/session.helpers';
import { AuthServiceClass } from '@/server/services/auth/auth.service';
import { db } from '@/server/db';
import { handleActionError } from '../utils/handle-action-error';
const input = z.object({ currentPassword: z.string(), newPassword: z.string() });
/**
* Given the current password and a new password, change the password of the current user.
*/
export const changePasswordAction = action(input, async ({ currentPassword, newPassword }) => {
try {
const user = await getUserFromCookie();
if (!user) {
throw new Error('User not found');
}
const authService = new AuthServiceClass(db);
await authService.changePassword({ userId: user.id, currentPassword, newPassword });
return { success: true };
} catch (e) {
return handleActionError(e);
}
});

View file

@ -0,0 +1,30 @@
'use server';
import { z } from 'zod';
import { action } from '@/lib/safe-action';
import { getUserFromCookie } from '@/server/common/session.helpers';
import { AuthServiceClass } from '@/server/services/auth/auth.service';
import { db } from '@/server/db';
import { handleActionError } from '../utils/handle-action-error';
const input = z.object({ password: z.string() });
/**
* Given a valid user password, disable TOTP for the user
*/
export const disableTotpAction = action(input, async ({ password }) => {
try {
const user = await getUserFromCookie();
if (!user) {
throw new Error('User not found');
}
const authService = new AuthServiceClass(db);
await authService.disableTotp({ userId: user.id, password });
return { success: true };
} catch (e) {
return handleActionError(e);
}
});

View file

@ -0,0 +1,30 @@
'use server';
import { z } from 'zod';
import { action } from '@/lib/safe-action';
import { getUserFromCookie } from '@/server/common/session.helpers';
import { AuthServiceClass } from '@/server/services/auth/auth.service';
import { db } from '@/server/db';
import { handleActionError } from '../utils/handle-action-error';
const input = z.object({ password: z.string() });
/**
* Given user's password, return the TOTP URI and key
*/
export const getTotpUriAction = action(input, async ({ password }) => {
try {
const user = await getUserFromCookie();
if (!user) {
throw new Error('User not found');
}
const authService = new AuthServiceClass(db);
const { key, uri } = await authService.getTotpUri({ userId: user.id, password });
return { success: true, key, uri };
} catch (e) {
return handleActionError(e);
}
});

View file

@ -0,0 +1,30 @@
'use server';
import { z } from 'zod';
import { action } from '@/lib/safe-action';
import { getUserFromCookie } from '@/server/common/session.helpers';
import { AuthServiceClass } from '@/server/services/auth/auth.service';
import { db } from '@/server/db';
import { handleActionError } from '../utils/handle-action-error';
const input = z.object({ totpCode: z.string() });
/**
* Given a valid user's TOTP code, activate TOTP for the user
*/
export const setupTotpAction = action(input, async ({ totpCode }) => {
try {
const user = await getUserFromCookie();
if (!user) {
throw new Error('User not found');
}
const authService = new AuthServiceClass(db);
await authService.setupTotp({ userId: user.id, totpCode });
return { success: true };
} catch (e) {
return handleActionError(e);
}
});

View file

@ -1,6 +1,5 @@
'use server'; 'use server';
import { z } from 'zod';
import { action } from '@/lib/safe-action'; import { action } from '@/lib/safe-action';
import { getUserFromCookie } from '@/server/common/session.helpers'; import { getUserFromCookie } from '@/server/common/session.helpers';
import { settingsSchema } from '@runtipi/shared'; import { settingsSchema } from '@runtipi/shared';
@ -10,7 +9,7 @@ import { handleActionError } from '../utils/handle-action-error';
/** /**
* Given a settings object, update the settings.json file * Given a settings object, update the settings.json file
*/ */
export const updateSettingsAction = action(settingsSchema, async () => { export const updateSettingsAction = action(settingsSchema, async (settings) => {
try { try {
const user = await getUserFromCookie(); const user = await getUserFromCookie();
@ -18,7 +17,7 @@ export const updateSettingsAction = action(settingsSchema, async () => {
throw new Error('Not authorized'); throw new Error('Not authorized');
} }
await setSettings(settingsSchema as z.infer<typeof settingsSchema>); await setSettings(settings);
return { success: true }; return { success: true };
} catch (e) { } catch (e) {

View file

@ -5,7 +5,7 @@ import nextConfig from 'next/config';
import { readJsonFile } from '../../common/fs.helpers'; import { readJsonFile } from '../../common/fs.helpers';
import { Logger } from '../Logger'; import { Logger } from '../Logger';
type TipiSettingsType = z.infer<typeof settingsSchema>; type TipiSettingsType = z.input<typeof settingsSchema>;
const formatErrors = (errors: { fieldErrors: Record<string, string[]> }) => const formatErrors = (errors: { fieldErrors: Record<string, string[]> }) =>
Object.entries(errors.fieldErrors) Object.entries(errors.fieldErrors)

View file

@ -8,12 +8,4 @@ const AuthService = new AuthServiceClass(db);
export const authRouter = router({ export const authRouter = router({
me: publicProcedure.query(async ({ ctx }) => AuthService.me(ctx.userId)), me: publicProcedure.query(async ({ ctx }) => AuthService.me(ctx.userId)),
changeLocale: protectedProcedure.input(z.object({ locale: z.string() })).mutation(async ({ input, ctx }) => AuthService.changeLocale({ userId: Number(ctx.userId), locale: input.locale })), changeLocale: protectedProcedure.input(z.object({ locale: z.string() })).mutation(async ({ input, ctx }) => AuthService.changeLocale({ userId: Number(ctx.userId), locale: input.locale })),
// Password
changePassword: protectedProcedure
.input(z.object({ currentPassword: z.string(), newPassword: z.string() }))
.mutation(({ input, ctx }) => AuthService.changePassword({ userId: Number(ctx.userId), ...input })),
// Totp
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 })),
}); });