feat: move security form to rsc
This commit is contained in:
parent
a65940dfb6
commit
f5e1725477
17 changed files with 201 additions and 77 deletions
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
|
|
31
src/app/actions/settings/change-password.ts
Normal file
31
src/app/actions/settings/change-password.ts
Normal 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);
|
||||
}
|
||||
});
|
30
src/app/actions/settings/disable-totp.ts
Normal file
30
src/app/actions/settings/disable-totp.ts
Normal 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);
|
||||
}
|
||||
});
|
30
src/app/actions/settings/get-totp-uri.ts
Normal file
30
src/app/actions/settings/get-totp-uri.ts
Normal 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);
|
||||
}
|
||||
});
|
30
src/app/actions/settings/setup-totp-action.ts
Normal file
30
src/app/actions/settings/setup-totp-action.ts
Normal 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);
|
||||
}
|
||||
});
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 })),
|
||||
});
|
||||
|
|
Loading…
Add table
Reference in a new issue