Skip to content
Merged
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
8 changes: 1 addition & 7 deletions src/api/auth/use-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,7 @@ const login = async (variables: Variables) => {
url: '/v1/users/sign_in',
method: 'POST',
data: {
user: {
email: variables.email,
password: variables.password,
},
},
headers: {
'Content-Type': 'application/json',
user: variables,
},
});
return data;
Expand Down
43 changes: 43 additions & 0 deletions src/api/auth/use-sign-up.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { createMutation } from 'react-query-kit';

import { client } from '../common';

type Variables = {
email: string;
name: string;
password: string;
passwordConfirmation: string;
};

type Response = {
status: string;
data: {
id: string;
email: string;
name: string;
provider: string;
uid: string;
allowPasswordChange: boolean;
createdAt: string;
updatedAt: string;
nickname?: string;
image?: string;
birthday?: string;
};
};

const signUp = async (variables: Variables) => {
const { data } = await client({
url: '/v1/users',
method: 'POST',
data: {
user: variables,
},
});

return data;
};

export const useSignUp = createMutation<Response, Variables>({
mutationFn: (variables) => signUp(variables),
});
42 changes: 38 additions & 4 deletions src/api/common/interceptors.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,59 @@
import type { AxiosError, InternalAxiosRequestConfig } from 'axios';

import { useAuth } from '@/core';
import { signIn, useAuth } from '@/core';

import { client } from './client';
import { toCamelCase, toSnakeCase } from './utils';

const ACCESS_TOKEN = 'access-token';
const CLIENT_HEADER = 'client';
const UID_HEADER = 'uid';
const EXPIRY_HEADER = 'expiry';
const AUTHORIZATION_HEADER = 'Authorization';

const CONTENT_TYPE = 'Content-Type';
const MULTIPART_FORM_DATA = 'multipart/form-data';

export default function interceptors() {
const token = useAuth.getState().token;
client.interceptors.request.use((config: InternalAxiosRequestConfig) => {
if (config.data) {
const token = useAuth.getState().token;

const { headers, data } = config;

if (headers && headers[CONTENT_TYPE] !== MULTIPART_FORM_DATA && data) {
config.data = toSnakeCase(config.data);
}

if (token) {
config.headers.Authorization = `Bearer ${token}`;
const { access, client: _client, uid, bearer, expiry } = token;

config.headers[AUTHORIZATION_HEADER] = bearer;
config.headers[ACCESS_TOKEN] = access;
config.headers[CLIENT_HEADER] = _client;
config.headers[UID_HEADER] = uid;
config.headers[EXPIRY_HEADER] = expiry;
}

return config;
});

client.interceptors.response.use(
(response) => {
const { data, headers } = response;
response.data = toCamelCase(response.data);

const token = headers[ACCESS_TOKEN];
const _client = headers[CLIENT_HEADER];
const uid = headers[UID_HEADER];
const expiry = headers[EXPIRY_HEADER];
const bearer = headers[AUTHORIZATION_HEADER];

if (token) {
signIn({ access: token, client: _client, uid, expiry, bearer });
}

response.data = toCamelCase(data);

return response;
},
(error: AxiosError) => Promise.reject(error),
Expand Down
4 changes: 4 additions & 0 deletions src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ export default function RootLayout() {
<Stack.Screen name="onboarding" options={{ headerShown: false }} />
<Stack.Screen name="forgot-password" />
<Stack.Screen name="sign-in" options={{ headerShown: false }} />
<Stack.Screen
name="sign-up"
options={{ headerBackTitleVisible: false }}
/>
<Stack.Screen
name="www"
options={{
Expand Down
10 changes: 4 additions & 6 deletions src/app/sign-in.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,13 @@ import { showMessage } from 'react-native-flash-message';
import { useLogin } from '@/api/auth/use-login';
import type { LoginFormProps } from '@/components/login-form';
import { LoginForm } from '@/components/login-form';
import { useAuth } from '@/core';
import { FocusAwareStatusBar } from '@/ui';

export default function Login() {
const router = useRouter();
const signIn = useAuth.use.signIn();
const { mutate: login } = useLogin({
onSuccess: (data) => {
signIn({ access: data.accessToken, refresh: data.refreshToken });

const { mutate: login, isPending } = useLogin({
onSuccess: () => {
router.push('/');
},
onError: (error) => showMessage({ message: error.message, type: 'danger' }),
Expand All @@ -25,7 +23,7 @@ export default function Login() {
return (
<>
<FocusAwareStatusBar />
<LoginForm onSubmit={onSubmit} />
<LoginForm onSubmit={onSubmit} isLoading={isPending} />
</>
);
}
30 changes: 30 additions & 0 deletions src/app/sign-up.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { useRouter } from 'expo-router';
import React from 'react';
import { showMessage } from 'react-native-flash-message';

import { useSignUp } from '@/api/auth/use-sign-up';
import type { SignUpFormProps } from '@/components/sign-up-form';
import { SignUpForm } from '@/components/sign-up-form';
import { FocusAwareStatusBar } from '@/ui';

export default function SignIn() {
const router = useRouter();

const { mutate: signUp, isPending } = useSignUp({
onSuccess: () => {
router.push('/');
},
onError: (error) => showMessage({ message: error.message, type: 'danger' }),
});

const onSubmit: SignUpFormProps['onSubmit'] = (data) => {
signUp(data);
};

return (
<>
<FocusAwareStatusBar />
<SignUpForm onSubmit={onSubmit} isPending={isPending} />
</>
);
}
84 changes: 55 additions & 29 deletions src/components/login-form.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,38 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Link } from 'expo-router';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
import z from 'zod';

import { translate } from '@/core';
import { Button, ControlledInput, Text, View } from '@/ui';

const MIN_CHARS = 6;
const schema = z.object({
email: z
.string({ required_error: 'Email is required' })
.email('Invalid email format'),
.string({
required_error: translate('auth.signIn.validation.emailRequired'),
})
.email(translate('auth.signIn.validation.invalidEmail')),
password: z
.string({
required_error: 'Password is required',
required_error: translate('auth.signIn.validation.passwordRequired'),
})
.min(MIN_CHARS, 'Password must be at least 6 characters'),
.min(MIN_CHARS, translate('auth.signIn.validation.passwordMinChars')),
});

export type FormType = z.infer<typeof schema>;

export type LoginFormProps = {
isLoading?: boolean;
onSubmit?: SubmitHandler<FormType>;
};

export const LoginForm = ({ onSubmit = () => {} }: LoginFormProps) => {
export const LoginForm = ({
onSubmit = () => {},
isLoading = false,
}: LoginFormProps) => {
const { handleSubmit, control } = useForm<FormType>({
resolver: zodResolver(schema),
});
Expand All @@ -34,32 +42,50 @@ export const LoginForm = ({ onSubmit = () => {} }: LoginFormProps) => {
behavior="padding"
keyboardVerticalOffset={10}
>
<View className="flex-1 justify-center p-4">
<Text testID="form-title" className="pb-6 text-center text-2xl">
Sign In
<View className="flex-1 justify-center gap-8 p-4">
<Text testID="form-title" className="text-center text-2xl">
{translate('auth.signIn.title')}
</Text>
<View>
<ControlledInput
testID="email-input"
autoCapitalize="none"
autoComplete="email"
control={control}
name="email"
label={translate('auth.signIn.fields.email')}
/>
<ControlledInput
testID="password-input"
control={control}
name="password"
label={translate('auth.signIn.fields.password')}
placeholder="***"
secureTextEntry={true}
/>

<ControlledInput
testID="email-input"
autoCapitalize="none"
autoComplete="email"
control={control}
name="email"
label="Email"
/>
<ControlledInput
testID="password-input"
control={control}
name="password"
label="Password"
placeholder="***"
secureTextEntry={true}
/>
<Button
testID="login-button"
label="Login"
onPress={handleSubmit(onSubmit)}
/>
<Button
testID="login-button"
label={translate('auth.signIn.buttons.login')}
onPress={handleSubmit(onSubmit)}
loading={isLoading}
/>
<Text>
{translate('auth.signIn.newAccount')}{' '}
<Link href="/sign-up" disabled={isLoading}>
<Text className="font-bold text-black">
{translate('auth.signIn.buttons.signUp')}
</Text>
</Link>
</Text>
<Link href="/forgot-password" disabled={isLoading} asChild>
<Button
variant="link"
className="font-bold text-black"
label={translate('auth.signIn.forgotPasswordButton')}
/>
</Link>
</View>
</View>
</KeyboardAvoidingView>
);
Expand Down
Loading
Loading