Skip to content

feat(auth): add user auth #7

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@
"react/prop-types": "off",
"react/no-unused-prop-types": "warn",
"react/forbid-prop-types": "warn",
"react/boolean-prop-naming": ["error", { "rule": "^(is|has|with)[A-Z]([A-Za-z0-9]?)+", "validateNested": true }],
"react/boolean-prop-naming": "off",
"react/jsx-max-props-per-line": ["warn", { "maximum": 1 }],
"react/jsx-first-prop-new-line": ["warn", "multiline"],
"react/jsx-props-no-spreading": "off",
Expand Down
113 changes: 113 additions & 0 deletions app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
'use client';

import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2, LogIn } from 'lucide-react';
import { useRouter } from 'next-nprogress-bar';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';

import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { signInUser } from '@/services/auth.service';

const LoginFormSchema = z.object({
email: z.string({ required_error: 'Required.' }).email('Invalid email address.'),
password: z.string().min(8, 'Password must contain at least 8 chars.'),
});

type LoginFormValues = z.infer<typeof LoginFormSchema>;

const LoginPage = () => {

const [ isLoading, setIsLoading ] = useState(false);

const router = useRouter();

const form = useForm<LoginFormValues>({ resolver: zodResolver(LoginFormSchema) });

const handleSubmit = async (values: LoginFormValues) => {
try {
setIsLoading(true);
await signInUser(values);
router.push('/admin');
} catch (error: any) {
toast.error(error.message);
} finally {
setIsLoading(false);
}
};

return (
<Form { ...form }>
<form
className="h-screen w-screen flex justify-center items-center"
onSubmit={ form.handleSubmit(handleSubmit) }
>
<Card>
<CardHeader>
<CardTitle>Login</CardTitle>
<CardDescription>Login to the administration space</CardDescription>
</CardHeader>
<CardContent>
<FormField
control={ form.control }
name="email"
render={ ({ field }) => (
<FormItem className="mb-4">
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="[email protected]"
type="email"
{ ...field }
/>
</FormControl>
<FormMessage />
</FormItem>
) }
/>
<FormField
control={ form.control }
name="password"
render={ ({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
placeholder="********"
type="password"
{ ...field }
/>
</FormControl>
<FormMessage />
</FormItem>
) }
/>
</CardContent>
<CardFooter className="justify-center">
<Button
className="gap-2"
disabled={ isLoading }
>
{
isLoading ?
<Loader2
className="animate-spin"
size="16"
/>
: <LogIn size="16" />
}
Login
</Button>
</CardFooter>
</Card>
</form>
</Form>
);
};

export default LoginPage;
26 changes: 26 additions & 0 deletions app/_components/Providers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use client';

import { AppProgressBar as ProgressBar } from 'next-nprogress-bar';
import { ReactNode } from 'react';
import { Toaster } from 'sonner';

const Providers = ({ children }: { children: ReactNode }) => {

return (
<>
<Toaster
closeButton
richColors
/>
{ children }
<ProgressBar
color="#000"
height="3px"
options={ { showSpinner: false } }
shallowRouting
/>
</>
);
};

export default Providers;
56 changes: 56 additions & 0 deletions app/admin/_components/AdminHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
'use client';

import Link from 'next/link';

import { NavigationMenu, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, navigationMenuTriggerStyle } from '@/components/ui/navigation-menu';

import UserDropdown from './UserDropdown';

const AdminHeader = () => {
return (
<nav className="p-4 border-b border-slate-300 flex justify-between">
<NavigationMenu>
<NavigationMenuList>
<NavigationMenuItem>
<Link
href="/admin"
legacyBehavior
passHref
>
<NavigationMenuLink
className={ navigationMenuTriggerStyle() }
>
Dashboard
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
<NavigationMenuItem>
<Link
href="/admin"
legacyBehavior
passHref
>
<NavigationMenuLink className={ navigationMenuTriggerStyle() }>
Projects
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
<NavigationMenuItem>
<Link
href="/admin"
legacyBehavior
passHref
>
<NavigationMenuLink className={ navigationMenuTriggerStyle() }>
Résumé
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
<UserDropdown />
</nav>
);
};

export default AdminHeader;
38 changes: 38 additions & 0 deletions app/admin/_components/UserDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { LogOut, User } from 'lucide-react';
import Link from 'next/link';

import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';

const UserDropdown = () => {

return (
<DropdownMenu>
<DropdownMenuTrigger>
<Avatar>
<AvatarImage
alt="Profile picture"
src=""
/>
<AvatarFallback className="bg-slate-200"><User size="16" /></AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/admin/account">
<User className="mr-2 h-4 w-4" />
<span>Profile</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive hover:!text-destructive">
<LogOut className="mr-2 h-4 w-4" />
<span>Log out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};

export default UserDropdown;
129 changes: 129 additions & 0 deletions app/admin/account/_components/UpdateEmailButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2, Save } from 'lucide-react';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';

import { Button } from '@/components/ui/button';
import ButtonItem from '@/components/ui/ButtonList/ButtonItem';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import useSession from '@/hooks/useSession';
import { updateUserEmail } from '@/services/auth.service';

const UpdateEmailFormSchema = z.object({
email: z.string({ required_error: 'Required.' }).email('Invalid email address.'),
password: z.string().min(8, 'Password must contain at least 8 chars.'),
});

type UpdateEmailFormValues = z.infer<typeof UpdateEmailFormSchema>;

const UpdateEmailButton = () => {

const [ isDialogOpen, setIsDialogOpen ] = useState(false);
const [ isLoading, setIsLoading ] = useState(false);
const { user, loading, refreshSession } = useSession();

const form = useForm<UpdateEmailFormValues>({
mode: 'onSubmit',
resolver: zodResolver(UpdateEmailFormSchema),
});

const handleSubmit = async (values: UpdateEmailFormValues) => {
try {
setIsLoading(true);
await updateUserEmail(values.email, {
email: user?.email || '',
password: values.password,
});
setIsDialogOpen(false);
} catch (error) {
console.error(error);
} finally {
setIsLoading(false);
}
};

const handleOpenChange = (isOpen: boolean) => {
setIsDialogOpen(isOpen);
};

const handleOpen = () => setIsDialogOpen(true);

return (
<Dialog
open={ isDialogOpen }
onOpenChange={ handleOpenChange }
>
<DialogTrigger asChild>
<ButtonItem
isLoading={ loading }
value={ user?.email }
onClick={ handleOpen }
>
Email
</ButtonItem>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<Form { ...form }>
<form onSubmit={ form.handleSubmit(handleSubmit) }>
<DialogHeader>
<DialogTitle>Update email</DialogTitle>
<DialogDescription>
Update your email address
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<FormField
control={ form.control }
name="email"
render={ ({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="[email protected]"
type="email"
{ ...field }
/>
</FormControl>
<FormMessage />
</FormItem>
) }
/>
<FormField
control={ form.control }
name="password"
render={ ({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
placeholder="********"
type="password"
{ ...field }
/>
</FormControl>
<FormMessage />
</FormItem>
) }
/>
</div>
<DialogFooter>
<Button
className="gap-2"
disabled={ isLoading }
>
{ isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save size="16" /> }
Save
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

export default UpdateEmailButton;
Loading