Skip to content

Commit

Permalink
feat: add account signin form
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelbrusegard committed Nov 7, 2024
1 parent 5561c91 commit a34397c
Show file tree
Hide file tree
Showing 11 changed files with 302 additions and 9 deletions.
Binary file modified bun.lockb
Binary file not shown.
30 changes: 28 additions & 2 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
"selectMonth": "Select month",
"selectYear": "Select year",
"pickDate": "Pick a date",
"dateFormat": "dd/MM/yyyy"
"dateFormat": "dd/MM/yyyy",
"hidePassword": "Hide password",
"showPassword": "Show password"
},
"error": {
"notFound": "404 - Page not found",
Expand All @@ -37,7 +39,31 @@
"signInWith": "Sign in with",
"hackerspaceAccount": "Hackerspace Account",
"success": "Success!",
"home": "Home"
"home": "Home",
"signIn": "Sign in",
"useYourAccount": "Use your account",
"forgotPassword": "Forgot password?",
"submit": "Submit",
"form": {
"username": {
"label": "Username",
"required": "Username is required",
"minLength": "Username must be at least 5 characters",
"maxLength": "Username must be less than 8 characters",
"invalid": "Username must contain only letters"
},
"password": {
"label": "Password",
"required": "Password is required",
"minLength": "Password must be at least 8 characters",
"maxLength": "Password must be less than 50 characters",
"uppercase": "Password must contain at least one uppercase letter",
"specialChar": "Password must contain at least one special character",
"confirmLabel": "Confirm password",
"mismatch": "Passwords do not match",
"weak": "Password is too weak"
}
}
},
"layout": {
"hackerspaceHome": "Hackerspace homepage",
Expand Down
30 changes: 28 additions & 2 deletions messages/no.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
"selectMonth": "Velg måned",
"selectYear": "Velg år",
"pickDate": "Velg en dato",
"dateFormat": "dd.MM.yyyy"
"dateFormat": "dd.MM.yyyy",
"hidePassword": "Gjem passord",
"showPassword": "Vis passord"
},
"error": {
"notFound": "404 - Siden ble ikke funnet",
Expand All @@ -37,7 +39,31 @@
"signInWith": "Logg inn med",
"hackerspaceAccount": "Hackerspace-konto",
"success": "Suksess!",
"home": "Hjem"
"home": "Hjem",
"signIn": "Logg inn",
"useYourAccount": "Bruk din konto",
"forgotPassword": "Glemt passord?",
"submit": "Send",
"form": {
"username": {
"label": "Brukernavn",
"required": "Brukernavn er påkrevd",
"minLength": "Brukernavn må være minst 5 tegn",
"maxLength": "Brukernavn må være mindre enn 8 tegn",
"invalid": "Brukernavn må kun inneholde bokstaver"
},
"password": {
"label": "Passord",
"required": "Passord er påkrevd",
"minLength": "Passord må være minst 8 tegn",
"maxLength": "Passord må være mindre enn 50 tegn",
"uppercase": "Passord må inneholde minst én stor bokstav",
"specialChar": "Passord må inneholde minst ett spesialtegn",
"confirmLabel": "Bekreft passord",
"mismatch": "Passordene stemmer ikke overens",
"weak": "Passordet er for svakt"
}
}
},
"layout": {
"hackerspaceHome": "Hackerspace hjemmeside",
Expand Down
12 changes: 12 additions & 0 deletions src/app/[locale]/auth/account/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { AccountSignInForm } from '@/components/auth/AccountSignInForm';
import { setRequestLocale } from 'next-intl/server';

export default async function AccountPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
return <AccountSignInForm />;
}
32 changes: 32 additions & 0 deletions src/app/[locale]/auth/create-account/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Button } from '@/components/ui/Button';
import { Separator } from '@/components/ui/Separator';
import { Link } from '@/lib/locale/navigation';
import { getTranslations, setRequestLocale } from 'next-intl/server';

export default async function CreateAccountPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('auth');
return (
<div className='flex h-full flex-col transition-opacity duration-500'>
<div className='mb-4 space-y-2 text-center'>
<h1 className='text-4xl'>{t('success')}</h1>
<p className='text-sm'>
{
'you are now a member of Hackerspace. Now you can finally start praying to our one true leader'
}
</p>
</div>
<Separator />
<div className='absolute bottom-0 space-y-4'>
<Button asChild>
<Link href='/'>{t('home')}</Link>
</Button>
</div>
</div>
);
}
14 changes: 10 additions & 4 deletions src/app/[locale]/auth/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { FeideLogo } from '@/components/assets/logos/FeideLogo';
import { Button } from '@/components/ui/Button';
import { Separator } from '@/components/ui/Separator';
import { Link } from '@/lib/locale/navigation';
import { FingerprintIcon } from 'lucide-react';
import { getTranslations, setRequestLocale } from 'next-intl/server';

Expand All @@ -21,12 +22,17 @@ export default async function SignInPage({
<Separator />
<div className='absolute bottom-0 space-y-4'>
<p className='text-center font-montserrat'>{t('signInWith')}</p>
<Button className='w-full bg-[#3FACC2]/90 hover:bg-[#3FACC2] dark:bg-[#222832] hover:dark:bg-[#222832]/90'>
<Button className='w-full bg-[#3FACC2]/90 hover:bg-[#3FACC2] dark:bg-[#222832] hover:dark:bg-[#222832]/40'>
<FeideLogo title='Feide' />
</Button>
<Button className='flex w-full gap-1 bg-primary/80 font-montserrat font-semibold text-black text-md dark:bg-primary/50 dark:text-white hover:dark:bg-primary/40'>
<FingerprintIcon className='text-accent dark:text-primary' />
{t('hackerspaceAccount')}
<Button
className='flex w-full gap-1 bg-primary/80 font-montserrat font-semibold text-black text-md dark:bg-primary/50 dark:text-white hover:dark:bg-primary/40'
asChild
>
<Link href='/auth/account'>
<FingerprintIcon className='text-accent dark:text-primary' />
{t('hackerspaceAccount')}
</Link>
</Button>
</div>
</div>
Expand Down
111 changes: 111 additions & 0 deletions src/components/auth/AccountSignInForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
'use client';

import { accountSignInSchema } from '@/validations/auth/accountSignInSchema';
import { useTranslations } from 'next-intl';
import { useState } from 'react';

import { api } from '@/lib/api/client';
import { Link } from '@/lib/locale/navigation';
import { useRouter } from '@/lib/locale/navigation';

import { usePending } from '@/components/auth/PendingBar';
import { PasswordInput } from '@/components/composites/PasswordInput';
import { Button } from '@/components/ui/Button';
import {
Form,
FormControl,
FormItem,
FormLabel,
FormMessage,
useForm,
} from '@/components/ui/Form';
import { Input } from '@/components/ui/Input';

function AccountSignInForm() {
const router = useRouter();
const t = useTranslations('auth');
const [accountCreated, setAccountCreated] = useState(false);
const formSchema = accountSignInSchema(t as (key: string) => string, false);
const { isPending, setPending } = usePending();

const form = useForm(formSchema, {
defaultValues: {
username: '',
password: '',
},
onSubmit: () => {
if (!accountCreated) {
router.push('/auth/create-account');
}
router.push('/');
},
});

return (
<div
className={`flex h-full flex-col transition-opacity duration-500 ${isPending ? 'pointer-events-none opacity-50' : ''}`}
>
<div className='mb-4 space-y-2 text-center'>
<h1 className='text-4xl'>{t('signIn')}</h1>
<p className='text-sm'>{t('useYourAccount')}</p>
</div>
<Form onSubmit={form.handleSubmit} className='flex-grow'>
<form.Field name='username'>
{(field) => (
<FormItem errors={field.state.meta.errors}>
<FormLabel>{t('form.username.label')}</FormLabel>
<FormControl>
<Input
placeholder='[email protected]'
autoComplete='username'
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
</form.Field>
<form.Field name='password'>
{(field) => (
<FormItem errors={field.state.meta.errors}>
<div className='flex items-center justify-between'>
<FormLabel>{t('form.password.label')}</FormLabel>
<Button
className='h-auto p-0 leading-none'
asChild
variant='link'
>
<Link href='/auth/forgot-password'>
{`${t('forgotPassword')}?`}
</Link>
</Button>
</div>
<FormControl>
<PasswordInput
autoComplete='current-password'
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
</form.Field>
<div className='absolute bottom-0 flex w-full xs:flex-row flex-col xs:justify-end justify-between gap-2'>
<form.Subscribe selector={(state) => [state.canSubmit]}>
{([canSubmit]) => (
<Button className='min-w-28' type='submit' disabled={!canSubmit}>
{t('submit')}
</Button>
)}
</form.Subscribe>
</div>
</Form>
</div>
);
}

export { AccountSignInForm };
48 changes: 48 additions & 0 deletions src/components/composites/PasswordInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
'use client';

import { EyeIcon, EyeOffIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
import * as React from 'react';

import { cx } from '@/lib/utils';

import { Button } from '@/components/ui/Button';
import { Input, type InputProps } from '@/components/ui/Input';

const PasswordInput = React.forwardRef<HTMLInputElement, InputProps>(
({ className, ...props }, ref) => {
const t = useTranslations('ui');
const [showPassword, setShowPassword] = React.useState(false);
const disabled =
props.value === '' || props.value === undefined || props.disabled;

return (
<div className='relative'>
<Input
type={showPassword ? 'text' : 'password'}
className={cx('pr-10', className)}
ref={ref}
{...props}
/>
<Button
type='button'
variant='ghost'
size='sm'
className='absolute top-0 right-0 h-full px-3 py-2 hover:bg-transparent'
onClick={() => setShowPassword((prev) => !prev)}
disabled={disabled}
aria-label={showPassword ? t('showPassword') : t('hidePassword')}
>
{showPassword && !disabled ? (
<EyeIcon className='h-4 w-4' aria-hidden='true' />
) : (
<EyeOffIcon className='h-4 w-4' aria-hidden='true' />
)}
</Button>
</div>
);
},
);
PasswordInput.displayName = 'PasswordInput';

export { PasswordInput };
5 changes: 4 additions & 1 deletion src/components/layout/header/ProfileMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/DropdownMenu';
import { Link } from '@/lib/locale/navigation';
import { UserIcon } from 'lucide-react';
import * as React from 'react';

Expand All @@ -20,7 +21,9 @@ function ProfileMenu({ t }: { t: { profile: string; signIn: string } }) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className='min-w-[6rem]' align='end'>
<DropdownMenuItem>{t.signIn}</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href='/auth'>{t.signIn}</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
Expand Down
4 changes: 4 additions & 0 deletions src/lib/locale/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ const routing = defineRouting({
en: '/auth',
no: '/autentisering',
},
'/auth/account': {
en: '/auth/account',
no: '/autentisering/konto',
},
'/auth/create-account': {
en: '/auth/create-account',
no: '/autentisering/opprett-konto',
Expand Down
25 changes: 25 additions & 0 deletions src/validations/auth/accountSignInSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { z } from 'zod';

function accountSignInSchema(t: (key: string) => string, isStrict = true) {
let passwordSchema = z.string().min(1, t('form.password.required'));

if (isStrict) {
passwordSchema = passwordSchema
.min(8, t('form.password.minLength'))
.max(50, t('form.password.maxLength'))
.regex(/[A-Z]/, t('form.password.uppercase'))
.regex(/[^a-zA-Z0-9]/, t('form.password.specialChar'));
}

return z.object({
username: z
.string()
.min(1, t('form.username.required'))
.min(5, t('form.username.minLength'))
.max(8, t('form.username.maxLength'))
.regex(/^[a-z]+$/, t('form.username.invalid')),
password: passwordSchema,
});
}

export { accountSignInSchema };

0 comments on commit a34397c

Please sign in to comment.