feat: 회원가입 api 연결 및 토큰 저장 후 Home 이동
This commit is contained in:
@@ -1,28 +1,48 @@
|
|||||||
|
import { LoginRequest, LoginResponse, SignUpRequest } from "./auth.types";
|
||||||
import { apiFetch } from "./client";
|
import { apiFetch } from "./client";
|
||||||
|
|
||||||
export type LoginRequest = {
|
export async function loginApi(body: LoginRequest): Promise<LoginResponse> {
|
||||||
loginId: string;
|
|
||||||
password: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function loginApi(body: LoginRequest): Promise<string> {
|
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch("/api/v1/auth/login", {
|
const res = await apiFetch("/api/v1/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("res status:", res.status);
|
const json = (await res.json()) as LoginResponse;
|
||||||
const json = (await res.json()) as any;
|
|
||||||
|
|
||||||
const token: string | undefined =
|
const accessToken = json?.data?.accessToken;
|
||||||
json?.data?.accessToken ?? json?.accessToken ?? json?.data?.token;
|
const refreshToken = json?.data?.refreshToken;
|
||||||
|
|
||||||
if (!token) throw new Error("No accessToken in response");
|
if (!accessToken || !refreshToken) {
|
||||||
|
throw new Error("Invalid login response: missing token");
|
||||||
|
}
|
||||||
|
|
||||||
return token;
|
return json;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("로그인 api 실패:", e);
|
console.error("로그인 api 실패:", e);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function signUpApi(body: SignUpRequest): Promise<LoginResponse> {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch("/api/v1/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = (await res.json()) as LoginResponse;
|
||||||
|
|
||||||
|
const accessToken = json?.data?.accessToken;
|
||||||
|
const refreshToken = json?.data?.refreshToken;
|
||||||
|
|
||||||
|
if (!accessToken || !refreshToken) {
|
||||||
|
throw new Error("Invalid login response: missing token");
|
||||||
|
}
|
||||||
|
|
||||||
|
return json;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("회원가입 api 실패:", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
18
src/api/auth.types.ts
Normal file
18
src/api/auth.types.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { ApiResponse } from "./response.types";
|
||||||
|
|
||||||
|
// 로그인 api
|
||||||
|
export type LoginRequest = {
|
||||||
|
loginId: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginData = {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginResponse = ApiResponse<LoginData>;
|
||||||
|
|
||||||
|
// 회원가입 api
|
||||||
|
export type SignUpRequest = { loginId: string; password: string };
|
||||||
|
export type SignUpResponse = { userId: number };
|
||||||
@@ -1,24 +1,43 @@
|
|||||||
function getApiBaseUrl() {
|
import { AppError, type ApiErrorPayload } from "./response.types";
|
||||||
|
|
||||||
|
function getApiBaseUrl(): string {
|
||||||
const url = process.env.EXPO_PUBLIC_API_BASE_URL?.trim();
|
const url = process.env.EXPO_PUBLIC_API_BASE_URL?.trim();
|
||||||
if (!url) throw new Error("Missing EXPO_PUBLIC_API_BASE_URL");
|
if (!url) throw new AppError("Missing EXPO_PUBLIC_API_BASE_URL");
|
||||||
return url.replace(/\/$/, "");
|
return url.replace(/\/$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiFetch(path: string, init?: RequestInit) {
|
export async function apiFetch(path: string, init?: RequestInit) {
|
||||||
const API_BASE_URL = getApiBaseUrl();
|
const baseUrl = getApiBaseUrl();
|
||||||
const safePath = path.startsWith("/") ? path : `/${path}`;
|
const safePath = path.startsWith("/") ? path : `/${path}`;
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}${safePath}`, {
|
let res: Response;
|
||||||
...init,
|
try {
|
||||||
headers: {
|
res = await fetch(`${baseUrl}${safePath}`, {
|
||||||
"Content-Type": "application/json",
|
...init,
|
||||||
...(init?.headers ?? {}),
|
headers: {
|
||||||
},
|
"Content-Type": "application/json",
|
||||||
});
|
...(init?.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new AppError("Network error", { httpStatus: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
throw new Error(`HTTP ${res.status} ${res.statusText} ${text}`);
|
let apiPayload: ApiErrorPayload | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = text ? JSON.parse(text) : null;
|
||||||
|
apiPayload = parsed?.data as ApiErrorPayload | undefined;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new AppError(apiPayload?.message ?? `HTTP ${res.status}`, {
|
||||||
|
httpStatus: res.status,
|
||||||
|
api: apiPayload,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
|
|||||||
31
src/api/response.types.ts
Normal file
31
src/api/response.types.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export type ApiResponse<T> = {
|
||||||
|
data: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiErrorPayload = {
|
||||||
|
title: string;
|
||||||
|
errorCode: string;
|
||||||
|
message: string;
|
||||||
|
code: number;
|
||||||
|
status: string;
|
||||||
|
instance?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class AppError extends Error {
|
||||||
|
readonly kind = "AppError";
|
||||||
|
readonly httpStatus?: number;
|
||||||
|
readonly api?: ApiErrorPayload;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
opts?: { httpStatus?: number; api?: ApiErrorPayload }
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.httpStatus = opts?.httpStatus;
|
||||||
|
this.api = opts?.api;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAppError(e: unknown): e is AppError {
|
||||||
|
return e instanceof AppError;
|
||||||
|
}
|
||||||
84
src/components/ui/AppModal.tsx
Normal file
84
src/components/ui/AppModal.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Modal, Pressable, StyleSheet, View } from "react-native";
|
||||||
|
import { Theme } from "../../theme/theme";
|
||||||
|
import AppText from "./AppText";
|
||||||
|
import Button from "./Button";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
visible: boolean;
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
primaryText?: string;
|
||||||
|
onPrimaryPress?: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AppModal({
|
||||||
|
visible,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
primaryText = "OK",
|
||||||
|
onPrimaryPress,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<Pressable style={styles.backdrop} onPress={onClose}>
|
||||||
|
<Pressable style={styles.card} onPress={() => {}}>
|
||||||
|
<AppText variant="title" style={styles.title}>
|
||||||
|
{title}
|
||||||
|
</AppText>
|
||||||
|
|
||||||
|
{message ? (
|
||||||
|
<AppText variant="muted" style={styles.message}>
|
||||||
|
{message}
|
||||||
|
</AppText>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={{ height: Theme.Spacing.lg }} />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
title={primaryText}
|
||||||
|
onPress={onPrimaryPress ?? onClose}
|
||||||
|
style={styles.button}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
backdrop: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: Theme.Spacing.xl,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.55)",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 420, // 큰 화면에서 너무 넓어지지 않게
|
||||||
|
alignSelf: "center",
|
||||||
|
borderRadius: Theme.Radius.lg,
|
||||||
|
backgroundColor: Theme.Colors.surface,
|
||||||
|
padding: Theme.Spacing.xl,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: Theme.Colors.border,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
// textAlign: "center",
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
// textAlign: "center",
|
||||||
|
marginTop: Theme.Spacing.md,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
minHeight: 48,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -2,18 +2,25 @@ import { useNavigation } from "@react-navigation/native";
|
|||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { StyleSheet, View } from "react-native";
|
import { StyleSheet, View } from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { isAppError } from "../api/response.types";
|
||||||
|
import AppModal from "../components/ui/AppModal";
|
||||||
import AppText from "../components/ui/AppText";
|
import AppText from "../components/ui/AppText";
|
||||||
import Button from "../components/ui/Button";
|
import Button from "../components/ui/Button";
|
||||||
import Input from "../components/ui/Input";
|
import Input from "../components/ui/Input";
|
||||||
|
import { useAuth } from "../store/auth";
|
||||||
import { Theme } from "../theme/theme";
|
import { Theme } from "../theme/theme";
|
||||||
|
|
||||||
export default function SignUpScreen() {
|
export default function SignUpScreen() {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
const auth = useAuth();
|
||||||
|
|
||||||
const [loginId, setLoginId] = useState("");
|
const [loginId, setLoginId] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [passwordConfirm, setPasswordConfirm] = useState("");
|
const [passwordConfirm, setPasswordConfirm] = useState("");
|
||||||
|
|
||||||
|
const [dupModalOpen, setDupModalOpen] = useState(false);
|
||||||
|
const [dupMessage, setDupMessage] = useState("");
|
||||||
|
|
||||||
const { canSubmit, errorText } = useMemo(() => {
|
const { canSubmit, errorText } = useMemo(() => {
|
||||||
const idOk = loginId.trim().length >= 4;
|
const idOk = loginId.trim().length >= 4;
|
||||||
const pwOk = password.length >= 8;
|
const pwOk = password.length >= 8;
|
||||||
@@ -33,7 +40,16 @@ export default function SignUpScreen() {
|
|||||||
}, [loginId, password, passwordConfirm]);
|
}, [loginId, password, passwordConfirm]);
|
||||||
|
|
||||||
const onSignUp = async () => {
|
const onSignUp = async () => {
|
||||||
navigation.goBack();
|
try {
|
||||||
|
await auth.signUp(loginId, password);
|
||||||
|
} catch (e) {
|
||||||
|
if (isAppError(e) && e.api?.errorCode === "AUTH-001") {
|
||||||
|
setDupMessage(e.api.message); // "이미 존재하는 loginId..."
|
||||||
|
setDupModalOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -98,6 +114,13 @@ export default function SignUpScreen() {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
onPress={() => navigation.goBack()}
|
onPress={() => navigation.goBack()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AppModal
|
||||||
|
visible={dupModalOpen}
|
||||||
|
title="That username is taken"
|
||||||
|
message="Please choose another."
|
||||||
|
onClose={() => setDupModalOpen(false)}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,16 +7,18 @@ import React, {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { loginApi } from "../api/auth";
|
import { loginApi, signUpApi } from "../api/auth";
|
||||||
|
|
||||||
const ACCESS_TOKEN_KEY = "accessToken";
|
const ACCESS_TOKEN_KEY = "accessToken";
|
||||||
|
const REFRESH_TOKEN_KEY = "refreshToken";
|
||||||
|
|
||||||
export type AuthContextValue = {
|
export type AuthContextValue = {
|
||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
isAuthed: boolean;
|
isAuthed: boolean;
|
||||||
isHydrating: boolean; // 앱 시작 시 저장소에서 토큰 읽는 중인지
|
isHydrating: boolean;
|
||||||
signIn: (loginId: string, password: string) => Promise<void>;
|
signIn: (loginId: string, password: string) => Promise<void>;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
|
signUp: (loginId: string, password: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
@@ -34,9 +36,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const signIn = useCallback(async (loginId: string, password: string) => {
|
const signIn = useCallback(async (loginId: string, password: string) => {
|
||||||
const token = await loginApi({ loginId, password });
|
const response = await loginApi({ loginId, password });
|
||||||
setAccessToken(token);
|
setAccessToken(response.data.accessToken);
|
||||||
await AsyncStorage.setItem(ACCESS_TOKEN_KEY, token);
|
await AsyncStorage.setItem(ACCESS_TOKEN_KEY, response.data.accessToken);
|
||||||
|
await AsyncStorage.setItem(REFRESH_TOKEN_KEY, response.data.accessToken);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const signOut = useCallback(async () => {
|
const signOut = useCallback(async () => {
|
||||||
@@ -44,6 +47,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
await AsyncStorage.removeItem(ACCESS_TOKEN_KEY);
|
await AsyncStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const signUp = useCallback(async (loginId: string, password: string) => {
|
||||||
|
const response = await signUpApi({ loginId, password });
|
||||||
|
setAccessToken(response.data.accessToken);
|
||||||
|
await AsyncStorage.setItem(ACCESS_TOKEN_KEY, response.data.accessToken);
|
||||||
|
await AsyncStorage.setItem(REFRESH_TOKEN_KEY, response.data.accessToken);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const value = useMemo<AuthContextValue>(
|
const value = useMemo<AuthContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
accessToken,
|
accessToken,
|
||||||
@@ -51,6 +61,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
isHydrating,
|
isHydrating,
|
||||||
signIn,
|
signIn,
|
||||||
signOut,
|
signOut,
|
||||||
|
signUp,
|
||||||
}),
|
}),
|
||||||
[accessToken, isHydrating, signIn, signOut]
|
[accessToken, isHydrating, signIn, signOut]
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user