added error handling, fixed authenticaton, added toaster

This commit is contained in:
lnn0q 2025-03-30 17:31:12 +03:00
parent 40c35bda13
commit b6b7cea210
18 changed files with 1327 additions and 68 deletions

View file

@ -3,3 +3,4 @@ VITE_PORT=4000
ACCESS_TOKEN_SECRET=sometoken1 ACCESS_TOKEN_SECRET=sometoken1
REFRESH_TOKEN_SECRET=sometoken2 REFRESH_TOKEN_SECRET=sometoken2
DATABASE_URL="mysql://janedoe:mypassword@localhost:3306/mydb" DATABASE_URL="mysql://janedoe:mypassword@localhost:3306/mydb"
MODE="dev"

View file

@ -1,2 +1,4 @@
app/client/components/ui app/client/components/ui
app/client/assets app/client/assets
vite.config.ts.timestamp-*.mjs

View file

@ -0,0 +1,134 @@
'use client';
import type { ButtonProps, GroupProps, InputProps, StackProps } from '@chakra-ui/react';
import {
Box,
HStack,
IconButton,
Input,
InputGroup,
Stack,
mergeRefs,
useControllableState
} from '@chakra-ui/react';
import * as React from 'react';
import { LuEye, LuEyeOff } from 'react-icons/lu';
export interface PasswordVisibilityProps {
defaultVisible?: boolean;
visible?: boolean;
onVisibleChange?: (visible: boolean) => void;
visibilityIcon?: { on: React.ReactNode; off: React.ReactNode };
}
export interface PasswordInputProps extends InputProps, PasswordVisibilityProps {
rootProps?: GroupProps;
}
export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
function PasswordInput(props, ref) {
const {
rootProps,
defaultVisible,
visible: visibleProp,
onVisibleChange,
visibilityIcon = { on: <LuEye />, off: <LuEyeOff /> },
...rest
} = props;
const [visible, setVisible] = useControllableState({
value: visibleProp,
defaultValue: defaultVisible || false,
onChange: onVisibleChange
});
const inputRef = React.useRef<HTMLInputElement>(null);
return (
<InputGroup
endElement={
<VisibilityTrigger
disabled={rest.disabled}
onPointerDown={e => {
if (rest.disabled) return;
if (e.button !== 0) return;
e.preventDefault();
setVisible(!visible);
}}
>
{visible ? visibilityIcon.off : visibilityIcon.on}
</VisibilityTrigger>
}
{...rootProps}
>
<Input {...rest} ref={mergeRefs(ref, inputRef)} type={visible ? 'text' : 'password'} />
</InputGroup>
);
}
);
const VisibilityTrigger = React.forwardRef<HTMLButtonElement, ButtonProps>(
function VisibilityTrigger(props, ref) {
return (
<IconButton
tabIndex={-1}
ref={ref}
me="-2"
aspectRatio="square"
size="sm"
variant="ghost"
height="calc(100% - {spacing.2})"
aria-label="Toggle password visibility"
{...props}
/>
);
}
);
interface PasswordStrengthMeterProps extends StackProps {
max?: number;
value: number;
}
export const PasswordStrengthMeter = React.forwardRef<HTMLDivElement, PasswordStrengthMeterProps>(
function PasswordStrengthMeter(props, ref) {
const { max = 4, value, ...rest } = props;
const percent = (value / max) * 100;
const { label, colorPalette } = getColorPalette(percent);
return (
<Stack align="flex-end" gap="1" ref={ref} {...rest}>
<HStack width="full" ref={ref} {...rest}>
{Array.from({ length: max }).map((_, index) => (
<Box
key={index}
height="1"
flex="1"
rounded="sm"
data-selected={index < value ? '' : undefined}
layerStyle="fill.subtle"
colorPalette="gray"
_selected={{
colorPalette,
layerStyle: 'fill.solid'
}}
/>
))}
</HStack>
{label && <HStack textStyle="xs">{label}</HStack>}
</Stack>
);
}
);
function getColorPalette(percent: number) {
switch (true) {
case percent < 33:
return { label: 'Low', colorPalette: 'red' };
case percent < 66:
return { label: 'Medium', colorPalette: 'orange' };
default:
return { label: 'High', colorPalette: 'green' };
}
}

View file

@ -0,0 +1,39 @@
'use client';
import {
Toaster as ChakraToaster,
Portal,
Spinner,
Stack,
Toast,
createToaster
} from '@chakra-ui/react';
export const toaster = createToaster({
placement: 'bottom-end',
pauseOnPageIdle: true
});
export const Toaster = () => {
return (
<Portal>
<ChakraToaster toaster={toaster} insetInline={{ mdDown: '4' }}>
{toast => (
<Toast.Root width={{ md: 'sm' }}>
{toast.type === 'loading' ? (
<Spinner size="sm" color="blue.solid" />
) : (
<Toast.Indicator />
)}
<Stack gap="1" flex="1" maxWidth="100%">
{toast.title && <Toast.Title>{toast.title}</Toast.Title>}
{toast.description && <Toast.Description>{toast.description}</Toast.Description>}
</Stack>
{toast.action && <Toast.ActionTrigger>{toast.action.label}</Toast.ActionTrigger>}
{toast.meta?.closable && <Toast.CloseTrigger />}
</Toast.Root>
)}
</ChakraToaster>
</Portal>
);
};

14
app/client/fragments.tsx Normal file
View file

@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const sessionFragment = gql`
fragment Session on Session {
accessToken
refreshToken {
token
maxAge
secure
httpOnly
sameSite
}
}
`;

View file

@ -0,0 +1,38 @@
import {
ApolloCache,
DefaultContext,
DocumentNode,
FetchResult,
MaybeMasked,
MutationFunctionOptions,
OperationVariables,
useMutation
} from '@apollo/client';
import { toaster } from '../components/ui/toaster';
interface UseToastMutationProps {
mutation: DocumentNode;
successMsg?: string;
}
interface UseToastMutationReturn<T> {
update: (
options?:
| MutationFunctionOptions<T, OperationVariables, DefaultContext, ApolloCache<any>>
| undefined
) => Promise<FetchResult<MaybeMasked<T>>>;
}
export function useToastMutation<T>(props: UseToastMutationProps): UseToastMutationReturn<T> {
const [update] = useMutation<T>(props.mutation, {
onCompleted: () => toaster.create({ description: props.successMsg, type: 'success' }),
onError: error => {
toaster.create({ description: `${error.message}`, type: 'error' });
console.error(error);
}
});
return {
update
};
}

View file

@ -2,6 +2,7 @@ import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client';
import '@client/assets/styles/index.css'; import '@client/assets/styles/index.css';
import '@client/assets/styles/nerd-fonts-icons.css'; import '@client/assets/styles/nerd-fonts-icons.css';
import { Provider } from '@client/components/ui/provider'; import { Provider } from '@client/components/ui/provider';
import { Toaster } from '@client/components/ui/toaster';
import { RouterProvider, createRouter } from '@tanstack/react-router'; import { RouterProvider, createRouter } from '@tanstack/react-router';
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
@ -37,6 +38,7 @@ if (!rootElement.innerHTML) {
<StrictMode> <StrictMode>
<ApolloProvider client={client}> <ApolloProvider client={client}>
<Provider> <Provider>
<Toaster />
<RouterProvider router={router} /> <RouterProvider router={router} />
</Provider> </Provider>
</ApolloProvider> </ApolloProvider>

View file

@ -1,23 +1,24 @@
// import { gql, useQuery } from '@apollo/client'; import { gql } from '@apollo/client';
import { Heading, Input, Stack } from '@chakra-ui/react'; import { Heading, Input, Stack } from '@chakra-ui/react';
import { Button } from '@client/components/ui/button'; import { Button } from '@client/components/ui/button';
import { Field } from '@client/components/ui/field'; import { Field } from '@client/components/ui/field';
import { valibotResolver } from '@hookform/resolvers/valibot'; import { valibotResolver } from '@hookform/resolvers/valibot';
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
import { Session } from 'inspector';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import * as v from 'valibot'; import * as v from 'valibot';
import { sessionFragment } from '../fragments';
import { useToastMutation } from '../hooks/useToastMutation';
export const Route = createFileRoute('/login')({ export const Route = createFileRoute('/login')({
component: LoginComponent component: LoginComponent
}); });
function LoginComponent() { function LoginComponent() {
// const { data } = useQuery(getBooksQuery);
return ( return (
<Stack flexDirection={'column'} justifyContent={'center'} alignItems={'center'}> <Stack flexDirection={'column'} justifyContent={'center'} alignItems={'center'}>
<Heading>Login</Heading> <Heading>Login</Heading>
<Login /> <LoginForm />
</Stack> </Stack>
); );
} }
@ -27,7 +28,12 @@ interface FormValues {
password: string; password: string;
} }
function Login() { function LoginForm() {
const { update: login } = useToastMutation<Session>({
mutation,
successMsg: 'Logged in successfully.'
});
const schema = v.pipe( const schema = v.pipe(
v.object({ v.object({
email: v.pipe(v.string(), v.nonEmpty('Email is required'), v.email('Invalid email')), email: v.pipe(v.string(), v.nonEmpty('Email is required'), v.email('Invalid email')),
@ -42,7 +48,19 @@ function Login() {
} = useForm<FormValues>({ resolver: valibotResolver(schema) }); } = useForm<FormValues>({ resolver: valibotResolver(schema) });
return ( return (
<form onSubmit={handleSubmit(data => alert(data))}> <form
onSubmit={handleSubmit(async values => {
const { data } = await login({
variables: {
input: {
email: values.email,
password: values.password
}
}
});
alert(JSON.stringify(data));
})}
>
<Stack> <Stack>
<Field label="Email" invalid={errors.email != null} errorText={errors.email?.message}> <Field label="Email" invalid={errors.email != null} errorText={errors.email?.message}>
<Input {...register('email')} /> <Input {...register('email')} />
@ -60,12 +78,12 @@ function Login() {
); );
} }
/* const getBooksQuery = gql` const mutation = gql`
query BooksMany { mutation AuthLogin($input: LoginInput!) {
booksMany { login(input: $input) {
id ...Session
author
title
} }
} }
`; */
${sessionFragment}
`;

View file

@ -1,23 +1,23 @@
// import { gql, useQuery } from '@apollo/client'; import { gql } from '@apollo/client';
import { Heading, Input, Stack } from '@chakra-ui/react'; import { Heading, Input, Stack } from '@chakra-ui/react';
import { Button } from '@client/components/ui/button'; import { Button } from '@client/components/ui/button';
import { Field } from '@client/components/ui/field'; import { Field } from '@client/components/ui/field';
import { Session } from '@generated/types';
import { valibotResolver } from '@hookform/resolvers/valibot'; import { valibotResolver } from '@hookform/resolvers/valibot';
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute, redirect } from '@tanstack/react-router';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import * as v from 'valibot'; import * as v from 'valibot';
import { useToastMutation } from '../hooks/useToastMutation';
export const Route = createFileRoute('/register')({ export const Route = createFileRoute('/register')({
component: RegisterComponent component: RegisterComponent
}); });
function RegisterComponent() { function RegisterComponent() {
// const { data } = useQuery(getBooksQuery);
return ( return (
<Stack flexDirection={'column'} justifyContent={'center'} alignItems={'center'}> <Stack flexDirection={'column'} justifyContent={'center'} alignItems={'center'}>
<Heading>Sign up</Heading> <Heading>Sign up</Heading>
<Register /> <SignUpForm />
</Stack> </Stack>
); );
} }
@ -29,7 +29,9 @@ interface FormValues {
confirm_password: string; confirm_password: string;
} }
function Register() { function SignUpForm() {
const { update: signUp } = useToastMutation<Session>({ mutation });
const schema = v.pipe( const schema = v.pipe(
v.object({ v.object({
email: v.pipe(v.string(), v.nonEmpty('Email is required'), v.email('Invalid email')), email: v.pipe(v.string(), v.nonEmpty('Email is required'), v.email('Invalid email')),
@ -53,7 +55,20 @@ function Register() {
formState: { errors } formState: { errors }
} = useForm<FormValues>({ resolver: valibotResolver(schema) }); } = useForm<FormValues>({ resolver: valibotResolver(schema) });
const onSubmit = handleSubmit(data => alert(data)); const onSubmit = handleSubmit(async values => {
await signUp({
variables: {
input: {
email: values.email,
username: values.username,
password: values.password
}
}
});
redirect({
to: '/login'
});
});
return ( return (
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
@ -88,12 +103,8 @@ function Register() {
); );
} }
/* const getBooksQuery = gql` const mutation = gql`
query BooksMany { mutation AuthSignUp($input: RegisterInput!) {
booksMany { signUp(input: $input)
id
author
title
}
} }
`; */ `;

View file

@ -7,6 +7,7 @@ import { BookOneResolver } from './resolvers/bookOneResolver';
/* Mutation */ /* Mutation */
import { LoginResolver } from './resolvers/auth/login'; import { LoginResolver } from './resolvers/auth/login';
import { SignUpResolver } from './resolvers/auth/signUp';
export const Query: QueryResolvers = { export const Query: QueryResolvers = {
booksMany: BooksManyResolver, booksMany: BooksManyResolver,
@ -14,7 +15,8 @@ export const Query: QueryResolvers = {
}; };
export const Mutation = { export const Mutation = {
login: LoginResolver login: LoginResolver,
signUp: SignUpResolver
}; };
export const Subscription = {}; export const Subscription = {};

View file

@ -9,12 +9,12 @@ export async function LoginResolver(
{ input }: MutationLoginArgs, { input }: MutationLoginArgs,
{ identity }: IdentityContext { identity }: IdentityContext
): Promise<Session> { ): Promise<Session> {
const error = 'Invalid username or password'; const error = 'Invalid password or email';
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { email: input.email } where: { email: input.email }
}); });
if (!user) throw new Error(error); if (user == null) throw new Error(error);
const match = await bcrypt.compare(input.password, user.password); const match = await bcrypt.compare(input.password, user.password);
if (!match) throw new Error(error); if (!match) throw new Error(error);

View file

@ -1,24 +1,24 @@
import { IdentityContext } from '@app/server/identity'; import { IdentityContext } from '@app/server/identity';
import { prisma, uuid } from '@app/server/prisma/client'; import { prisma, uuid } from '@app/server/prisma/client';
import { RegisterInput } from '@generated/types'; import { MutationRegisterArgs } from '@generated/types';
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
export async function SignUpResolver( export async function SignUpResolver(
_: any, _: any,
input: RegisterInput, { input }: MutationRegisterArgs,
{ identity }: IdentityContext { identity }: IdentityContext
): Promise<void> { ): Promise<void> {
const existingUsername = await prisma.user.findUnique({ const existingUsername = await prisma.user.findUnique({
where: { username: input.username }, where: { username: input.username },
select: { id: true } select: { id: true }
}); });
if (existingUsername != null) throw Error('User with this username already exists.'); if (existingUsername != null) throw new Error('User with this username already exists.');
const existingEmail = await prisma.user.findUnique({ const existingEmail = await prisma.user.findUnique({
where: { email: input.email }, where: { email: input.email },
select: { id: true } select: { id: true }
}); });
if (existingEmail != null) throw Error('Email is already in use.'); if (existingEmail != null) throw new Error('Email is already in use.');
const hashedPassword = await bcrypt.hash(input.password, 10); const hashedPassword = await bcrypt.hash(input.password, 10);

View file

@ -7,7 +7,7 @@ type Query {
type Mutation { type Mutation {
login(input: LoginInput!): Session! login(input: LoginInput!): Session!
register(input: RegisterInput!): Void! signUp(input: RegisterInput!): Void
} }
input LoginInput { input LoginInput {

View file

@ -31,7 +31,14 @@ const resolvers = {
const apollo = new ApolloServer<IdentityContext>({ const apollo = new ApolloServer<IdentityContext>({
typeDefs, typeDefs,
resolvers, resolvers,
plugins: [ApolloServerPluginDrainHttpServer({ httpServer: server })] plugins: [ApolloServerPluginDrainHttpServer({ httpServer: server })],
formatError: (formattedError, error) => {
process.stdout.write('\n```\n');
console.error(`${error}`);
process.stdout.write('\n```\n');
return formattedError;
}
}); });
await apollo.start(); await apollo.start();

View file

@ -14,7 +14,7 @@ model User {
username String @unique @db.VarChar(255) username String @unique @db.VarChar(255)
password String password String
refreshToken String? refreshToken String? @db.VarChar(255)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

1044
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,12 +9,14 @@
"build": "vite build", "build": "vite build",
"gql-types": "graphql-codegen --config codegen.ts", "gql-types": "graphql-codegen --config codegen.ts",
"checkup": "opinionated checkup", "checkup": "opinionated checkup",
"prisma": "prisma" "prisma": "prisma",
"chakra": "chakra"
}, },
"dependencies": { "dependencies": {
"@apollo/client": "^3.12.2", "@apollo/client": "^3.12.2",
"@apollo/server": "^4.11.2", "@apollo/server": "^4.11.2",
"@chakra-ui/react": "^3.2.3", "@chakra-ui/react": "^3.2.3",
"@chakra-ui/toast": "^7.0.2",
"@deftomat/opinionated": "^1.1.3", "@deftomat/opinionated": "^1.1.3",
"@emotion/react": "^11.13.5", "@emotion/react": "^11.13.5",
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
@ -33,7 +35,7 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-icons": "^5.4.0", "react-icons": "^5.5.0",
"tsx": "^4.3.0", "tsx": "^4.3.0",
"typescript": "^5.3.2", "typescript": "^5.3.2",
"uuid": "^11.1.0", "uuid": "^11.1.0",
@ -41,6 +43,7 @@
"vite-express": "^0.20.0" "vite-express": "^0.20.0"
}, },
"devDependencies": { "devDependencies": {
"@chakra-ui/cli": "^3.15.0",
"@graphql-codegen/cli": "5.0.3", "@graphql-codegen/cli": "5.0.3",
"@graphql-codegen/client-preset": "4.5.1", "@graphql-codegen/client-preset": "4.5.1",
"@graphql-codegen/introspection": "4.0.3", "@graphql-codegen/introspection": "4.0.3",