Skip to content

Commit

Permalink
API per current docs, name changes, server-only package, stuff (#21)
Browse files Browse the repository at this point in the history
- /api/admin is per `auth.js` docs.

- SocialButtons.tsx renamed and address `callbackUrl deprecated`

- hooks with 'use client'

- remove `<SessionProvider>` from root layout, usage for client components in app router

- `SessionProvider` is not used on root layout, navbar refactored to have UserAvatarMenu serverside for session, clientPage refactored and created client component for demo

- Settings page is now server-side and grab user session server-side. `Now there is no flickering on form load`.

- install package `'server-only'`

- introduction of `'server-only'` package for auth-utils.ts

- /actions/register checks for 'production' since dev ip is proxied

- login action fix bug where user with 2FA on and with wrong password, would still move to 2fa logic and send email.

- middleware.ts no need to check isApiAuthRoute anymore, fixed rate limit

- rename UserInfo.tsx AuthFormHeader.tsx

- UserAvatarMenu.tsx moved, renamed and now server side auth

- Moved some components for better organization
  • Loading branch information
zenWai authored Oct 2, 2024
1 parent 83e9486 commit a564665
Show file tree
Hide file tree
Showing 31 changed files with 358 additions and 302 deletions.
18 changes: 17 additions & 1 deletion actions/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { AuthError } from 'next-auth';
import * as zod from 'zod';
import bcrypt from 'bcryptjs';

import { getVerificationTokenByEmail } from '@/data/verification-token';
import { getTwoFactorConfirmationByUserId } from '@/data/two-factor-confirmation';
Expand All @@ -28,6 +29,15 @@ export const login = async (values: zod.infer<typeof LoginSchema>, callbackUrl?:
return { error: 'Invalid credentials' };
}

// Verify password before proceeding with any other checks
const passwordsMatch = await bcrypt.compare(password, existingUser.password);
if (!passwordsMatch) {
return { error: 'Invalid credentials' };
}

/** Confirmation email token recently sent?
* if not, generates and send email
*/
if (!existingUser.emailVerified) {
const existingToken = await getVerificationTokenByEmail(email);
if (existingToken) {
Expand All @@ -44,7 +54,12 @@ export const login = async (values: zod.infer<typeof LoginSchema>, callbackUrl?:
return { success: 'Confirmation email sent!' };
}

/** 2FA code logic
* Currently if current token is unexpired it does not re-send a new one
* Reduce db calls and e-mail sents on this preview
*/
if (existingUser.isTwoFactorEnabled && existingUser.email) {
// If user is already at the 2fa on loginForm
if (code) {
const twoFactorToken = await getTwoFactorTokenByEmail(existingUser.email);
if (!twoFactorToken) {
Expand All @@ -70,11 +85,12 @@ export const login = async (values: zod.infer<typeof LoginSchema>, callbackUrl?:
where: { id: existingConfirmation.id },
});
}

// consumed by the signIn callback
await db.twoFactorConfirmation.create({
data: { userId: existingUser.id },
});
} else {
// return { twoFactor: true }; sends the user to the 2fa on loginForm
const existingTwoFactorToken = await getTwoFactorTokenByEmail(existingUser.email);
if (existingTwoFactorToken) {
const hasExpired = new Date(existingTwoFactorToken.expires) < new Date();
Expand Down
19 changes: 8 additions & 11 deletions actions/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,29 +14,26 @@ import { generateVerificationToken } from '@/lib/tokens';
export const register = async (values: zod.infer<typeof RegisterSchema>) => {
const validatedFields = RegisterSchema.safeParse(values);

if (!validatedFields.success) return { error: 'Invalid fields' };

const { email, password, name } = validatedFields.data;

const headersList = headers();
const userIp = headersList.get('request-ip');
const hashedIp = await hashIp(userIp);

if (userIp === '127.0.0.1' || !userIp || hashedIp === 'unknown') {
return { error: 'Sorry! Something went wrong. Please try again later.' };
/* If we can not determine the IP of the user, fails to register */
if ((process.env.NODE_ENV === 'production' && userIp === '127.0.0.1') || !userIp || hashedIp === 'unknown') {
return { error: 'Sorry! Something went wrong. Could not identify you as user' };
}

const existingAccounts = await db.user.count({
where: { ip: hashedIp },
});
if (existingAccounts >= 2) {
if (process.env.NODE_ENV === 'production' && existingAccounts >= 2) {
return { error: 'You are not allowed to register more accounts on this app preview' };
}

if (!validatedFields.success) {
return {
error: 'Invalid fields',
};
}

const { email, password, name } = validatedFields.data;

const existingUser = await getUserByEmail(email);
if (existingUser) {
return { error: 'Email already registered!' };
Expand Down
30 changes: 0 additions & 30 deletions app/(protected)/_components/Navbar.tsx

This file was deleted.

4 changes: 2 additions & 2 deletions app/(protected)/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { UserRole } from '@prisma/client';

import { AdminOnlyRhAndSa } from '@/app/(protected)/_components/admin-only-rh-and-sa';
import { RoleGate } from '@/components/auth/RoleGate';
import { AdminOnlyRhAndSa } from '@/components/admin-only-rh-and-sa';
import { RoleGate } from '@/components/RoleGate';
import { FormSuccess } from '@/components/form-messages/FormSuccess';
import { Card, CardContent, CardHeader } from '@/components/ui/card';

Expand Down
14 changes: 14 additions & 0 deletions app/(protected)/client/client-component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
'use client';

import { useCurrentUser } from '@/hooks/use-current-user';
import UserInfo from '@/components/UserInfo';

export default function ClientComponent() {
const userSession = useCurrentUser();
return (
<div className=''>
{/* This userInfo component is what we call a hybrid component, as children of a client component, is a client component */}
<UserInfo label='💃Client component' user={userSession} />
</div>
);
}
13 changes: 6 additions & 7 deletions app/(protected)/client/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
'use client';
import UserInfo from '@/components/user-info';
import { useCurrentUser } from '@/hooks/use-current-user';
import { SessionProvider } from 'next-auth/react';

import ClientComponent from '@/app/(protected)/client/client-component';

export default function ClientPage() {
const userSession = useCurrentUser();
return (
<div className=''>
<UserInfo label='💃Client component' user={userSession} />
</div>
<SessionProvider>
<ClientComponent />
</SessionProvider>
);
}
9 changes: 7 additions & 2 deletions app/(protected)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { Navbar } from '@/app/(protected)/_components/Navbar';
import { NavigationMenu } from '@/components/navbar/NavigationMenu';
import { UserAvatarMenu } from '@/components/navbar/UserAvatarMenu';
import { Navbar } from '@/components/navbar/Navbar';

export default function ProtectedLayout(props: { children: React.ReactNode }) {
return (
<div
className='flex h-full w-full flex-col items-center justify-center gap-y-10 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))]
from-sky-400 to-blue-800'
>
<Navbar />
<Navbar>
<NavigationMenu />
<UserAvatarMenu />
</Navbar>
{props.children}
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion app/(protected)/server/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import UserInfo from '@/components/user-info';
import UserInfo from '@/components/UserInfo';
import { currentSessionUser } from '@/lib/auth-utils';

export default async function ServerPage() {
Expand Down
189 changes: 189 additions & 0 deletions app/(protected)/settings/SettingsForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
'use client';
import * as zod from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { UserRole } from '@prisma/client';
import { useSession } from 'next-auth/react';
import { useEffect, useState, useTransition } from 'react';

import type { ExtendedUser } from '@/next-auth';
import { settings } from '@/actions/settings';
import { FormError } from '@/components/form-messages/FormError';
import { FormSuccess } from '@/components/form-messages/FormSuccess';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { SettingsSchema } from '@/schemas';

export function SettingsForm({ user }: { user: ExtendedUser }) {
const { update } = useSession();

const [error, setError] = useState<string | undefined>();
const [success, setSuccess] = useState<string | undefined>();
const [isPending, startTransition] = useTransition();

const defaultValues = {
name: user?.name || '',
email: user?.email || '',
password: undefined,
newPassword: undefined,
role: user?.role || UserRole.USER,
isTwoFactorEnabled: user?.isTwoFactorEnabled || undefined,
};

const form = useForm<zod.infer<typeof SettingsSchema>>({
resolver: zodResolver(SettingsSchema),
defaultValues,
});

useEffect(() => {
if (user) {
form.reset(defaultValues);
}
}, [user?.name, user?.email, user?.id, user?.image, user?.isOauth, user?.isTwoFactorEnabled, user?.role, form.reset]);

const onSubmit = (values: zod.infer<typeof SettingsSchema>) => {
setError('');
setSuccess('');
startTransition(() => {
settings(values)
.then((data) => {
if (data.error) {
setError(data.error);
}
if (data.success) {
// updates client side session
update();
setSuccess(data.success);
}
})
.catch(() => setError('An error occurred!'));
});
};
return (
<>
<Form {...form}>
<form className='space-y-6' onSubmit={form.handleSubmit(onSubmit)}>
<div className='space-y-4'>
{/* Name */}
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} disabled={!user || isPending} placeholder='John Doe' />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Content not shown to oauth users */}
{/* Email and Password */}
{user?.isOauth === false && (
<>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
{...field}
disabled={!user || isPending}
placeholder='[email protected]'
type='email'
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input {...field} disabled={!user || isPending} placeholder='123456' type='password' />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='newPassword'
render={({ field }) => (
<FormItem>
<FormLabel>New Password</FormLabel>
<FormControl>
<Input {...field} disabled={!user || isPending} placeholder='123456' type='password' />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{/* Role */}
<FormField
control={form.control}
name='role'
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select
// need to set the default value does not come from form defaults
defaultValue={defaultValues.role}
disabled={isPending}
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder='Select a role' />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={UserRole.ADMIN}>Admin</SelectItem>
<SelectItem value={UserRole.USER}>User</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Two Factor Authentication not shown to oauth users */}
{user?.isOauth === false && (
<FormField
control={form.control}
name='isTwoFactorEnabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm'>
<div className='space-y-5'>
<FormLabel>Two Factor Authentication</FormLabel>
<FormDescription>Enable Two Factor Authentication for your account</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} disabled={isPending} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
)}
</div>
<FormError message={error} />
<FormSuccess message={success} />
<Button disabled={isPending} type='submit'>
Save
</Button>
</form>
</Form>
</>
);
}
Loading

0 comments on commit a564665

Please sign in to comment.