Skip to content
Open
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,6 @@ VITE_KAKAO_REDIRECT_URI=http://localhost:5173/
현재 연동 범위:

- `pg-auth-service`: 카카오 로그인, 약관 동의, 가맹점 가입, 로그아웃
- `merchant-service`: 로그인 가맹점 기본정보 조회
- `merchant-service`: 로그인 가맹점 기본정보 조회/수정
- PG 관리자 웹: 관리자 로그인, 감사로그 조회, 가입 대기 가맹점 조회/승인
- 매출, 거래, 정산: 대응 백엔드 API가 준비될 때까지 목 데이터 사용
2 changes: 2 additions & 0 deletions src/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ const importPg = () => import("./web-pg/styles.css")
.then(() => import("./web-pg/components.jsx"))
.then(() => import("./web-pg/layout.jsx"))
.then(() => import("./web-pg/data.jsx"))
.then(() => import("./web-pg/api/backend-api.jsx"))
.then(() => import("./web-pg/screens/dashboard.jsx"))
.then(() => import("./web-pg/screens/approvals.jsx"))
.then(() => import("./web-pg/screens/merchants.jsx"))
.then(() => import("./web-pg/screens/transactions.jsx"))
.then(() => import("./web-pg/screens/settlements.jsx"))
Expand Down
125 changes: 98 additions & 27 deletions src/web-merchant/api/backend-api.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ const MERCHANT_API_BASE_URL = import.meta.env.VITE_MERCHANT_API_BASE_URL || "/ba
const SESSION_KEY = "erumpay.merchant.session";
const PROFILE_KEY = "erumpay.merchant.profile";

// Remove tokens left by older builds that used persistent browser storage.
try {
localStorage.removeItem(SESSION_KEY);
localStorage.removeItem(PROFILE_KEY);
} catch {
// Storage may be unavailable in restricted browser contexts.
}

class ApiError extends Error {
constructor(message, status, code, details) {
super(message);
Expand Down Expand Up @@ -51,35 +59,96 @@ const request = async (baseUrl, path, options = {}) => {
const AuthSession = {
get: () => {
try {
return JSON.parse(localStorage.getItem(SESSION_KEY)) || {};
return JSON.parse(sessionStorage.getItem(SESSION_KEY)) || {};
} catch {
return {};
}
},
set: (value) => localStorage.setItem(SESSION_KEY, JSON.stringify(value)),
set: (value) => sessionStorage.setItem(SESSION_KEY, JSON.stringify(value)),
merge: (value) => AuthSession.set({ ...AuthSession.get(), ...value }),
clear: () => {
localStorage.removeItem(SESSION_KEY);
localStorage.removeItem(PROFILE_KEY);
sessionStorage.removeItem(SESSION_KEY);
sessionStorage.removeItem(PROFILE_KEY);
},
isAuthenticated: () => {
const session = AuthSession.get();
return Boolean(session.accessToken && session.status === "ACTIVE");
},
isAuthenticated: () => Boolean(AuthSession.get().accessToken),
getProfile: () => {
try {
return JSON.parse(localStorage.getItem(PROFILE_KEY));
return JSON.parse(sessionStorage.getItem(PROFILE_KEY));
} catch {
return null;
}
},
setProfile: (profile) => localStorage.setItem(PROFILE_KEY, JSON.stringify(profile)),
setProfile: (profile) => sessionStorage.setItem(PROFILE_KEY, JSON.stringify(profile)),
};

let refreshTokenMemory = null;

const getAuthHeaders = () => {
const { accessToken } = AuthSession.get();
return accessToken ? { Authorization: `Bearer ${accessToken}` } : {};
};

const refreshAccessToken = async () => {
const { refreshToken } = AuthSession.get();
if (!refreshToken) {
throw new ApiError("로그인 세션이 만료되었습니다.", 401, "REFRESH_TOKEN_REQUIRED");
}

const response = await request(AUTH_API_BASE_URL, "/api/v1/auth/token/refresh", {
method: "POST",
headers: { Authorization: `Bearer ${refreshToken}` },
body: JSON.stringify({ refresh_token: refreshToken }),
});
AuthSession.merge({
accessToken: response.access_token,
refreshToken: response.refresh_token || refreshToken,
});
return response.access_token;
};

let refreshPromise = null;

const getRefreshedAccessToken = () => {
if (!refreshPromise) {
refreshPromise = refreshAccessToken()
.catch(error => {
AuthSession.clear();
throw error;
})
.finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
};

const authorizedRequest = async (baseUrl, path, options = {}) => {
const accessTokenAtRequest = AuthSession.get().accessToken;
try {
return await request(baseUrl, path, {
...options,
headers: { ...getAuthHeaders(), ...options.headers },
});
} catch (error) {
const currentSession = AuthSession.get();
if (error.status !== 401 || !currentSession.refreshToken) throw error;

let accessToken = currentSession.accessToken;
if (!accessToken || accessToken === accessTokenAtRequest) {
accessToken = await getRefreshedAccessToken();
}

return request(baseUrl, path, {
...options,
headers: {
Authorization: `Bearer ${accessToken}`,
...options.headers,
},
});
}
};

const AuthApi = {
getKakaoAuthorizeUrl: () => {
const clientId = import.meta.env.VITE_KAKAO_CLIENT_ID;
Expand All @@ -105,11 +174,12 @@ const AuthApi = {
merchantId: response.merchant_id,
status: response.status,
accessToken: response.access_token,
refreshToken: response.refresh_token,
signupToken: response.signup_token,
});
refreshTokenMemory = response.refresh_token || null;
return response;
},
refresh: refreshAccessToken,
agreeTerms: async ({ serviceTermsAgreed, privacyPolicyAgreed, marketingAgreed }) => {
const { signupToken } = AuthSession.get();
return request(AUTH_API_BASE_URL, "/api/v1/auth/merchant/terms/agree", {
Expand Down Expand Up @@ -146,35 +216,36 @@ const AuthApi = {
},
logout: async () => {
const session = AuthSession.get();
const refreshToken = refreshTokenMemory || session.refreshToken;
if (refreshToken) {
await request(AUTH_API_BASE_URL, "/api/v1/auth/merchant/logout", {
method: "POST",
headers: { Authorization: `Bearer ${refreshToken}` },
body: JSON.stringify({
refresh_token: refreshToken,
access_token: session.accessToken,
}),
});
try {
if (session.refreshToken) {
await request(AUTH_API_BASE_URL, "/api/v1/auth/merchant/logout", {
method: "POST",
headers: { Authorization: `Bearer ${session.refreshToken}` },
body: JSON.stringify({
refresh_token: session.refreshToken,
access_token: session.accessToken,
}),
});
}
} finally {
AuthSession.clear();
}
refreshTokenMemory = null;
AuthSession.clear();
},
};

const MerchantBackendApi = {
getMerchant: async (merchantId = AuthSession.get().merchantId) => {
if (!merchantId) return null;
const profile = await request(MERCHANT_API_BASE_URL, `/api/v1/pg-admin/merchants/${merchantId}`, {
headers: getAuthHeaders(),
});
const profile = await authorizedRequest(
MERCHANT_API_BASE_URL,
`/api/v1/pg-admin/merchants/${merchantId}`
);
AuthSession.setProfile(profile);
return profile;
},
updateMerchant: async (merchantId, payload) => {
const profile = await request(MERCHANT_API_BASE_URL, `/api/v1/pg-admin/merchants/${merchantId}`, {
const profile = await authorizedRequest(MERCHANT_API_BASE_URL, `/api/v1/pg-admin/merchants/${merchantId}`, {
method: "PUT",
headers: getAuthHeaders(),
body: JSON.stringify(payload),
});
AuthSession.setProfile(profile);
Expand Down
64 changes: 54 additions & 10 deletions src/web-merchant/screens/auth.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ const SignupInfo = ({ onPrev, onSubmit }) => {
serviceName: "",
businessAddress: "",
businessAddressDetail: "",
businessRegistrationFile: null,
});
const [submitting, setSubmitting] = React.useState(false);
const [error, setError] = React.useState("");
Expand All @@ -206,7 +207,12 @@ const SignupInfo = ({ onPrev, onSubmit }) => {
<AuthInput name="representativeName" value={form.representativeName} onChange={update} label="대표자명" placeholder="대표자 이름을 입력하세요"/>
<AuthInput name="mccCode" value={form.mccCode} onChange={update} label="MCC 코드" placeholder="숫자 4자리"/>
<AuthInput name="serviceName" value={form.serviceName} onChange={update} label="서비스명" placeholder="가맹점 서비스명을 입력하세요"/>
<AuthUpload label="사업자등록증 첨부" wide/>
<AuthUpload
label="사업자등록증 첨부"
file={form.businessRegistrationFile}
onChange={file => update("businessRegistrationFile", file)}
wide
/>
</FormSection>

<FormSection title="담당자 정보">
Expand Down Expand Up @@ -271,16 +277,54 @@ const AuthInput = ({ name, value, onChange, label, placeholder, type = "text", w
</label>
);

const AuthUpload = ({ label, wide }) => (
<div className={`auth-field ${wide ? "wide" : ""}`}>
<span>{label}<em>*</em></span>
<div className="upload-box">
<Icons.Download size={20}/>
<strong>클릭하여 파일 업로드</strong>
<small>JPG, PNG, PDF 파일만 가능</small>
const AuthUpload = ({ label, file, onChange, wide }) => {
const inputId = React.useId();
const [error, setError] = React.useState("");
const maxSize = 10 * 1024 * 1024;
const allowedTypes = ["image/jpeg", "image/png", "application/pdf"];

const selectFile = event => {
const selectedFile = event.target.files?.[0] || null;
setError("");

if (!selectedFile) return;
if (!allowedTypes.includes(selectedFile.type)) {
setError("JPG, PNG, PDF 파일만 선택할 수 있습니다.");
event.target.value = "";
return;
}
if (selectedFile.size > maxSize) {
setError("파일 크기는 최대 10MB까지 가능합니다.");
event.target.value = "";
return;
}

onChange(selectedFile);
};

return (
<div className={`auth-field ${wide ? "wide" : ""}`}>
<span>{label}<em>*</em></span>
<input
id={inputId}
className="upload-input"
type="file"
accept=".jpg,.jpeg,.png,.pdf,image/jpeg,image/png,application/pdf"
onChange={selectFile}
/>
<label className={`upload-box ${file ? "selected" : ""}`} htmlFor={inputId}>
<Icons.Download size={20}/>
<strong>{file ? file.name : "클릭하여 파일 업로드"}</strong>
<small>
{file
? `${(file.size / 1024 / 1024).toFixed(2)}MB · 다른 파일을 선택하려면 클릭하세요`
: "JPG, PNG, PDF (최대 10MB)"}
</small>
</label>
{error && <small className="upload-error">{error}</small>}
</div>
</div>
);
);
};

const ReviewComplete = ({ onEnterMain, onCheckReview = onEnterMain }) => (
<div className="merchant-auth-shell">
Expand Down
32 changes: 32 additions & 0 deletions src/web-merchant/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1883,18 +1883,50 @@ table.tbl {
background: #FBFCFD;
color: var(--text-secondary);
text-align: center;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease;
}

.upload-box:hover,
.upload-box:focus-within {
border-color: var(--c-main);
background: #F2FBF8;
}

.upload-box.selected {
border-color: var(--c-main);
background: #F2FBF8;
color: var(--c-main);
}

.upload-box strong {
color: var(--text-secondary);
font-size: 12px;
overflow-wrap: anywhere;
}

.upload-box small {
font-size: 10px;
color: var(--text-tertiary);
}

.upload-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

.upload-error {
color: var(--c-danger);
font-size: 10px;
}

.review-complete-copy {
text-align: center;
}
Expand Down
Loading