feat: 회원가입 api 연결 및 토큰 저장 후 Home 이동

This commit is contained in:
2026-01-02 14:58:05 +09:00
parent 014cb61734
commit 355c9d7743
7 changed files with 235 additions and 29 deletions

View File

@@ -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
View 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 };

View File

@@ -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
View 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;
}

View 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,
},
});

View File

@@ -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>
);

View File

@@ -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]
);