feat: 회원가입 api 연결 및 토큰 저장 후 Home 이동
This commit is contained in:
@@ -1,28 +1,48 @@
|
||||
import { LoginRequest, LoginResponse, SignUpRequest } from "./auth.types";
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
export type LoginRequest = {
|
||||
loginId: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export async function loginApi(body: LoginRequest): Promise<string> {
|
||||
export async function loginApi(body: LoginRequest): Promise<LoginResponse> {
|
||||
try {
|
||||
const res = await apiFetch("/api/v1/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
console.log("res status:", res.status);
|
||||
const json = (await res.json()) as any;
|
||||
const json = (await res.json()) as LoginResponse;
|
||||
|
||||
const token: string | undefined =
|
||||
json?.data?.accessToken ?? json?.accessToken ?? json?.data?.token;
|
||||
const accessToken = json?.data?.accessToken;
|
||||
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) {
|
||||
console.error("로그인 api 실패:", 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();
|
||||
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(/\/$/, "");
|
||||
}
|
||||
|
||||
export async function apiFetch(path: string, init?: RequestInit) {
|
||||
const API_BASE_URL = getApiBaseUrl();
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const safePath = path.startsWith("/") ? path : `/${path}`;
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}${safePath}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${baseUrl}${safePath}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
throw new AppError("Network error", { httpStatus: 0 });
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
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;
|
||||
|
||||
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 { StyleSheet, View } from "react-native";
|
||||
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 Button from "../components/ui/Button";
|
||||
import Input from "../components/ui/Input";
|
||||
import { useAuth } from "../store/auth";
|
||||
import { Theme } from "../theme/theme";
|
||||
|
||||
export default function SignUpScreen() {
|
||||
const navigation = useNavigation();
|
||||
const auth = useAuth();
|
||||
|
||||
const [loginId, setLoginId] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordConfirm, setPasswordConfirm] = useState("");
|
||||
|
||||
const [dupModalOpen, setDupModalOpen] = useState(false);
|
||||
const [dupMessage, setDupMessage] = useState("");
|
||||
|
||||
const { canSubmit, errorText } = useMemo(() => {
|
||||
const idOk = loginId.trim().length >= 4;
|
||||
const pwOk = password.length >= 8;
|
||||
@@ -33,7 +40,16 @@ export default function SignUpScreen() {
|
||||
}, [loginId, password, passwordConfirm]);
|
||||
|
||||
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 (
|
||||
@@ -98,6 +114,13 @@ export default function SignUpScreen() {
|
||||
variant="secondary"
|
||||
onPress={() => navigation.goBack()}
|
||||
/>
|
||||
|
||||
<AppModal
|
||||
visible={dupModalOpen}
|
||||
title="That username is taken"
|
||||
message="Please choose another."
|
||||
onClose={() => setDupModalOpen(false)}
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
@@ -7,16 +7,18 @@ import React, {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { loginApi } from "../api/auth";
|
||||
import { loginApi, signUpApi } from "../api/auth";
|
||||
|
||||
const ACCESS_TOKEN_KEY = "accessToken";
|
||||
const REFRESH_TOKEN_KEY = "refreshToken";
|
||||
|
||||
export type AuthContextValue = {
|
||||
accessToken: string | null;
|
||||
isAuthed: boolean;
|
||||
isHydrating: boolean; // 앱 시작 시 저장소에서 토큰 읽는 중인지
|
||||
isHydrating: boolean;
|
||||
signIn: (loginId: string, password: string) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
signUp: (loginId: string, password: string) => Promise<void>;
|
||||
};
|
||||
|
||||
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 token = await loginApi({ loginId, password });
|
||||
setAccessToken(token);
|
||||
await AsyncStorage.setItem(ACCESS_TOKEN_KEY, token);
|
||||
const response = await loginApi({ 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 signOut = useCallback(async () => {
|
||||
@@ -44,6 +47,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
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>(
|
||||
() => ({
|
||||
accessToken,
|
||||
@@ -51,6 +61,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
isHydrating,
|
||||
signIn,
|
||||
signOut,
|
||||
signUp,
|
||||
}),
|
||||
[accessToken, isHydrating, signIn, signOut]
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user