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

View File

@ -1,5 +1,4 @@
import React from 'react';
import { trpc } from '@/utils/trpc';
import { Switch } from '@/components/ui/Switch';
import { Button } from '@/components/ui/Button';
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 { useDisclosure } from '@/client/hooks/useDisclosure';
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 = () => {
const globalT = useTranslations();
export const OtpForm = (props: { totpEnabled: boolean }) => {
const { totpEnabled } = props;
const t = useTranslations('settings.security');
const [password, setPassword] = React.useState('');
const [key, setKey] = React.useState('');
@ -23,54 +25,53 @@ export const OtpForm = () => {
const setupOtpDisclosure = useDisclosure();
const disableOtpDisclosure = useDisclosure();
const ctx = trpc.useContext();
const me = trpc.auth.me.useQuery();
const getTotpUri = trpc.auth.getTotpUri.useMutation({
onMutate: () => {
const getTotpUriMutation = useAction(getTotpUriAction, {
onExecute: () => {
setupOtpDisclosure.close();
},
onError: (e) => {
setPassword('');
toast.error(globalT(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables }));
},
onSuccess: (data) => {
setKey(data.key);
setUri(data.uri);
if (!data.success) {
setPassword('');
toast.error(data.failure.reason);
} else {
setKey(data.key);
setUri(data.uri);
}
},
});
const setupTotp = trpc.auth.setupTotp.useMutation({
onMutate: () => {},
onError: (e) => {
setTotpCode('');
toast.error(globalT(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables }));
},
onSuccess: () => {
setTotpCode('');
setKey('');
setUri('');
toast.success(t('2fa-enable-success'));
ctx.auth.me.invalidate();
const setupTotpMutation = useAction(setupTotpAction, {
onSuccess: (data) => {
if (!data.success) {
setTotpCode('');
toast.error(data.failure.reason);
} else {
setTotpCode('');
setKey('');
setUri('');
toast.success(t('2fa-enable-success'));
// ctx.auth.me.invalidate();
}
},
});
const disableTotp = trpc.auth.disableTotp.useMutation({
onMutate: () => {
const disableTotpMutation = useAction(disableTotpAction, {
onExecute: () => {
disableOtpDisclosure.close();
},
onError: (e) => {
setPassword('');
toast.error(globalT(e.data?.tError.message as MessageKey, { ...e.data?.tError?.variables }));
},
onSuccess: () => {
toast.success(t('2fa-disable-success'));
ctx.auth.me.invalidate();
onSuccess: (data) => {
if (!data.success) {
setPassword('');
toast.error(data.failure.reason);
} else {
toast.success(t('2fa-disable-success'));
//ctx.auth.me.invalidate();
}
},
});
const renderSetupQr = () => {
if (!uri || me.data?.totpEnabled) return null;
if (!uri || totpEnabled) return null;
return (
<div className="mt-4">
@ -85,7 +86,7 @@ export const OtpForm = () => {
<div className="mb-4">
<p className="text-muted">{t('enter-2fa-code')}</p>
<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')}
</Button>
</div>
@ -103,8 +104,8 @@ export const OtpForm = () => {
return (
<>
{!key && <Switch disabled={!me.isSuccess} onCheckedChange={handleTotp} checked={me.data?.totpEnabled} label={t('enable-2fa')} />}
{getTotpUri.isLoading && (
{!key && <Switch onCheckedChange={handleTotp} checked={totpEnabled} label={t('enable-2fa')} />}
{getTotpUriMutation.isExecuting && (
<div className="progress w-50">
<div className="progress-bar progress-bar-indeterminate bg-green" />
</div>
@ -119,12 +120,12 @@ export const OtpForm = () => {
<form
onSubmit={(e) => {
e.preventDefault();
getTotpUri.mutate({ password });
getTotpUriMutation.execute({ password });
}}
>
<p className="text-muted">{t('password-needed-hint')}</p>
<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')}
</Button>
</form>
@ -140,12 +141,12 @@ export const OtpForm = () => {
<form
onSubmit={(e) => {
e.preventDefault();
disableTotp.mutate({ password });
disableTotpMutation.execute({ password });
}}
>
<p className="text-muted">{t('password-needed-hint')}</p>
<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')}
</Button>
</form>

View File

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

View File

@ -8,6 +8,8 @@ import { getCurrentLocale } from 'src/utils/getCurrentLocale';
import { SettingsTabTriggers } from './components/SettingsTabTriggers';
import { GeneralActions } from './components/GeneralActions';
import { SettingsContainer } from './components/SettingsContainer';
import { SecurityContainer } from './components/SecurityContainer';
import { getUserFromCookie } from '@/server/common/session.helpers';
export async function generateMetadata(): Promise<Metadata> {
const translator = await getTranslatorFromCookie();
@ -23,6 +25,7 @@ export default async function SettingsPage({ searchParams }: { searchParams: { t
const version = await systemService.getVersion();
const settings = getSettings();
const locale = getCurrentLocale();
const user = await getUserFromCookie();
return (
<div className="card d-flex">
@ -34,7 +37,9 @@ export default async function SettingsPage({ searchParams }: { searchParams: { t
<TabsContent value="settings">
<SettingsContainer initialValues={settings} currentLocale={locale} />
</TabsContent>
<TabsContent value="security">{/* <SecurityContainer /> */}</TabsContent>
<TabsContent value="security">
<SecurityContainer totpEnabled={Boolean(user?.totpEnabled)} />
</TabsContent>
</Tabs>
</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';
import { z } from 'zod';
import { action } from '@/lib/safe-action';
import { getUserFromCookie } from '@/server/common/session.helpers';
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
*/
export const updateSettingsAction = action(settingsSchema, async () => {
export const updateSettingsAction = action(settingsSchema, async (settings) => {
try {
const user = await getUserFromCookie();
@ -18,7 +17,7 @@ export const updateSettingsAction = action(settingsSchema, async () => {
throw new Error('Not authorized');
}
await setSettings(settingsSchema as z.infer<typeof settingsSchema>);
await setSettings(settings);
return { success: true };
} catch (e) {

View File

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

View File

@ -8,12 +8,4 @@ const AuthService = new AuthServiceClass(db);
export const authRouter = router({
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 })),
// 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 })),
});