feat: 학생용 '내 정보' 확인·수정 페이지 (/profile)
- GET/PUT /api/profile/me — 경로에 id 없이 세션에서 본인 식별 (IDOR 원천 차단, 주석화)
- 수정 가능: 이름·회사이메일·생년월일·연락처·주소·학교·전공·비상연락처
불가: 아이디(불변)·트랙(멘토 소관)·동의시각(증빙은 본인도 수정 불가)
- 이메일 변경 시에만 중복 검사(내 값 유지는 통과), 검증 규칙은 가입과 동일
- 수정할 엔티티는 트랜잭션 안 재조회(managed) — 비밀번호 변경 버그의 교훈 적용
- DTO 변환을 트랜잭션 안에서 — open-in-view:false의 LAZY 함정 회피 (주석화)
- 수정 시 멘토 텔레그램 알림 (경리 CSV 재발급 필요 알림)
- 네비 👤 아이콘 (학생만 표시), Field 컴포넌트는 밖에 정의(키보드 버그 교훈 참조)
- 테스트 3개 추가 → 전체 25개 통과
운영 E2E 검증: 조회→이메일·주소 수정→재조회 반영 확인.
배경: 가입한 학생 2명의 주소가 불완전하고 이메일 오타 의심 — 본인이 직접 교정 가능하게.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0336213ca2
commit
f557d7a189
@ -102,6 +102,25 @@ public class StudentProfile {
|
||||
this.privacyConsentAt = privacyConsentAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* 내 정보 수정 — 가입 때 적은 인적사항의 오타·변경을 본인이 고칠 수 있게.
|
||||
* 학습 포인트: setter를 열어두지 않고 "수정"이라는 의도가 담긴 메서드 하나로 받는다.
|
||||
* (동의 시각 privacyConsentAt은 여기 없다 — 증빙은 본인도 수정할 수 없어야 하니까.)
|
||||
*/
|
||||
public void update(String companyEmailLocal, LocalDate birthDate, String phone,
|
||||
String address, String school, String major,
|
||||
String emergencyName, String emergencyRelation, String emergencyPhone) {
|
||||
this.companyEmailLocal = companyEmailLocal;
|
||||
this.birthDate = birthDate;
|
||||
this.phone = phone;
|
||||
this.address = address;
|
||||
this.school = school;
|
||||
this.major = major;
|
||||
this.emergencyName = emergencyName;
|
||||
this.emergencyRelation = emergencyRelation;
|
||||
this.emergencyPhone = emergencyPhone;
|
||||
}
|
||||
|
||||
/** 회사 도메인은 여기 한 곳에서만 정의한다. 바뀌면 이 상수만 고친다. */
|
||||
public static final String COMPANY_DOMAIN = "awesomedev.dev";
|
||||
|
||||
|
||||
@ -65,6 +65,11 @@ public class User {
|
||||
* 아예 없으면, 실수로라도 그런 코드를 쓸 수 없다(엔티티가 스스로 규칙을 지킨다).
|
||||
* 여기에는 반드시 "이미 해시된 값"만 들어와야 한다 — 인코딩은 서비스 계층의 책임이다.
|
||||
*/
|
||||
/** 이름 변경 (내 정보 수정에서 오타 교정용). */
|
||||
public void rename(String newName) {
|
||||
this.name = newName;
|
||||
}
|
||||
|
||||
public void changePassword(String newPasswordHash) {
|
||||
this.passwordHash = newPasswordHash;
|
||||
}
|
||||
|
||||
@ -1,9 +1,15 @@
|
||||
package dev.awesomedev.mirim.service;
|
||||
|
||||
import dev.awesomedev.mirim.domain.StudentProfile;
|
||||
import dev.awesomedev.mirim.domain.User;
|
||||
import dev.awesomedev.mirim.repository.StudentProfileRepository;
|
||||
import dev.awesomedev.mirim.repository.UserRepository;
|
||||
import dev.awesomedev.mirim.web.dto.ProfileResponse;
|
||||
import dev.awesomedev.mirim.web.dto.ProfileUpdateRequest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
@ -19,9 +25,61 @@ import java.util.List;
|
||||
public class StudentProfileService {
|
||||
|
||||
private final StudentProfileRepository studentProfileRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final TelegramNotifier telegramNotifier;
|
||||
|
||||
public StudentProfileService(StudentProfileRepository studentProfileRepository) {
|
||||
public StudentProfileService(StudentProfileRepository studentProfileRepository,
|
||||
UserRepository userRepository,
|
||||
TelegramNotifier telegramNotifier) {
|
||||
this.studentProfileRepository = studentProfileRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.telegramNotifier = telegramNotifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* 내 인적사항 조회.
|
||||
* 학습 포인트: DTO 변환(ProfileResponse.from)이 p.getUser()를 만지는데 user는 LAZY다.
|
||||
* open-in-view: false에서는 트랜잭션 밖에서 만지면 예외가 나므로,
|
||||
* 엔티티가 아니라 "변환이 끝난 DTO"를 트랜잭션 안에서 만들어 반환한다.
|
||||
* (멘토 명단의 JOIN FETCH와 같은 함정을 다른 방법으로 푼 것 — 방법은 여러 가지다.)
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public ProfileResponse myProfile(User loginUser) {
|
||||
StudentProfile profile = studentProfileRepository.findByUser(loginUser)
|
||||
.orElseThrow(() -> new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "인적사항이 없습니다. (멘토 계정에는 인적사항이 없어요.)"));
|
||||
return ProfileResponse.from(profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* 내 인적사항 수정.
|
||||
* 학습 포인트: 비밀번호 변경 버그의 교훈 그대로 — 수정할 엔티티는 반드시
|
||||
* 트랜잭션 "안에서" 다시 조회한다(managed). 세션에서 온 loginUser는 detached라
|
||||
* 바꿔도 저장되지 않는다.
|
||||
*/
|
||||
@Transactional
|
||||
public ProfileResponse updateMyProfile(User loginUser, ProfileUpdateRequest req) {
|
||||
User user = userRepository.findById(loginUser.getId())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."));
|
||||
StudentProfile profile = studentProfileRepository.findByUser(user)
|
||||
.orElseThrow(() -> new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "인적사항이 없습니다. (멘토 계정에는 인적사항이 없어요.)"));
|
||||
|
||||
// 이메일 아이디를 "다른 값으로" 바꿀 때만 중복 검사 — 내 것 그대로면 통과해야 한다.
|
||||
if (!profile.getCompanyEmailLocal().equals(req.companyEmailLocal())
|
||||
&& studentProfileRepository.existsByCompanyEmailLocal(req.companyEmailLocal())) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 사용 중인 회사 이메일 아이디입니다.");
|
||||
}
|
||||
|
||||
user.rename(req.name());
|
||||
profile.update(req.companyEmailLocal(), req.birthDate(), req.phone(), req.address(),
|
||||
req.school(), req.major(),
|
||||
req.emergencyName(), req.emergencyRelation(), req.emergencyPhone());
|
||||
|
||||
// 멘토에게 알림 — 경리 서류(CSV)를 이미 뽑았다면 다시 뽑아야 하니까.
|
||||
telegramNotifier.send("✏️ 내 정보 수정\n" + user.getName() + " (" + user.getUsername() + ")");
|
||||
|
||||
return ProfileResponse.from(profile);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
package dev.awesomedev.mirim.web;
|
||||
|
||||
import dev.awesomedev.mirim.domain.User;
|
||||
import dev.awesomedev.mirim.service.AuthService;
|
||||
import dev.awesomedev.mirim.service.StudentProfileService;
|
||||
import dev.awesomedev.mirim.web.dto.ProfileResponse;
|
||||
import dev.awesomedev.mirim.web.dto.ProfileUpdateRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* "내 정보" API — 본인 인적사항 조회(GET)와 수정(PUT).
|
||||
*
|
||||
* 학습 포인트: 경로에 사용자 id가 없다(/api/profile/me).
|
||||
* "누구의 정보인가"를 URL이 아니라 로그인 세션에서 얻기 때문에,
|
||||
* 남의 id를 넣어 남의 정보를 조회·수정하는 공격(IDOR)이 원천적으로 불가능하다.
|
||||
* — URL에 /users/{id}/profile 처럼 id를 받는 설계였다면 매번 "본인인가?"를 검사해야 했다.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/profile")
|
||||
public class ProfileController {
|
||||
|
||||
private final AuthService authService;
|
||||
private final StudentProfileService studentProfileService;
|
||||
|
||||
public ProfileController(AuthService authService, StudentProfileService studentProfileService) {
|
||||
this.authService = authService;
|
||||
this.studentProfileService = studentProfileService;
|
||||
}
|
||||
|
||||
/** GET /api/profile/me — 내 인적사항 */
|
||||
@GetMapping("/me")
|
||||
public ProfileResponse myProfile(Authentication authentication) {
|
||||
User user = authService.requireUser(authentication);
|
||||
return studentProfileService.myProfile(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/profile/me — 내 인적사항 수정.
|
||||
* 학습 포인트: 부분 수정이 아니라 전체 교체라서 PUT이다(PATCH가 아니라).
|
||||
* 화면이 현재 값을 전부 채워서 보내므로 "빠진 필드 = 지워짐" 혼란이 없다.
|
||||
*/
|
||||
@PutMapping("/me")
|
||||
public ProfileResponse updateMyProfile(@Valid @RequestBody ProfileUpdateRequest request,
|
||||
Authentication authentication) {
|
||||
User user = authService.requireUser(authentication);
|
||||
return studentProfileService.updateMyProfile(user, request);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
package dev.awesomedev.mirim.web.dto;
|
||||
|
||||
import dev.awesomedev.mirim.domain.StudentProfile;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* "내 정보" 화면에 내려줄 인적사항 응답. 화면이 쓰는 것만 담는다.
|
||||
*/
|
||||
public record ProfileResponse(
|
||||
String username,
|
||||
String name,
|
||||
String track,
|
||||
String companyEmailLocal,
|
||||
String fullEmail,
|
||||
LocalDate birthDate,
|
||||
String phone,
|
||||
String address,
|
||||
String school,
|
||||
String major,
|
||||
String emergencyName,
|
||||
String emergencyRelation,
|
||||
String emergencyPhone
|
||||
) {
|
||||
/** 엔티티 → 응답 DTO 변환. 컨트롤러마다 반복하지 않게 한 곳에 둔다. */
|
||||
public static ProfileResponse from(StudentProfile p) {
|
||||
return new ProfileResponse(
|
||||
p.getUser().getUsername(), p.getUser().getName(), p.getUser().getTrack(),
|
||||
p.getCompanyEmailLocal(), p.fullEmail(), p.getBirthDate(), p.getPhone(),
|
||||
p.getAddress(), p.getSchool(), p.getMajor(),
|
||||
p.getEmergencyName(), p.getEmergencyRelation(), p.getEmergencyPhone());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
package dev.awesomedev.mirim.web.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Past;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* "내 정보 수정" 요청 본문. 가입 때 적은 인적사항 중 본인이 고칠 수 있는 것들이다.
|
||||
*
|
||||
* 학습 포인트: 검증 규칙은 SignupRequest와 "일부러 똑같다".
|
||||
* 가입할 땐 막히던 값이 수정으로는 들어간다면 검증이 뚫린 것이나 마찬가지다 —
|
||||
* 같은 데이터라면 들어오는 문이 달라도 규칙은 같아야 한다.
|
||||
* (아이디·비밀번호·트랙은 여기 없다 — 아이디는 불변, 비밀번호는 전용 API,
|
||||
* 트랙 변경은 인사 사항이라 멘토 소관이다.)
|
||||
*/
|
||||
public record ProfileUpdateRequest(
|
||||
|
||||
@NotBlank(message = "이름은 필수입니다.")
|
||||
String name,
|
||||
|
||||
@NotBlank(message = "회사 이메일 아이디는 필수입니다.")
|
||||
@Size(min = 2, max = 40, message = "이메일 아이디는 2~40자입니다.")
|
||||
@Pattern(regexp = "^[a-z0-9._-]+$", message = "이메일 아이디는 소문자·숫자·. _ - 만 쓸 수 있어요.")
|
||||
String companyEmailLocal,
|
||||
|
||||
@NotNull(message = "생년월일은 필수입니다.")
|
||||
@Past(message = "생년월일은 오늘보다 이전이어야 합니다.")
|
||||
LocalDate birthDate,
|
||||
|
||||
@NotBlank(message = "연락처는 필수입니다.")
|
||||
@Pattern(regexp = "^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$", message = "휴대폰 번호 형식이 올바르지 않아요.")
|
||||
String phone,
|
||||
|
||||
@NotBlank(message = "주소는 필수입니다.")
|
||||
String address,
|
||||
|
||||
@NotBlank(message = "학교는 필수입니다.")
|
||||
String school,
|
||||
|
||||
@NotBlank(message = "전공(학과)은 필수입니다.")
|
||||
String major,
|
||||
|
||||
@NotBlank(message = "비상 연락처 이름은 필수입니다.")
|
||||
String emergencyName,
|
||||
|
||||
@NotBlank(message = "비상 연락처 관계는 필수입니다.")
|
||||
String emergencyRelation,
|
||||
|
||||
@NotBlank(message = "비상 연락처 전화번호는 필수입니다.")
|
||||
@Pattern(regexp = "^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$", message = "비상 연락처 전화번호 형식이 올바르지 않아요.")
|
||||
String emergencyPhone
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,100 @@
|
||||
package dev.awesomedev.mirim.service;
|
||||
|
||||
import dev.awesomedev.mirim.domain.StudentProfile;
|
||||
import dev.awesomedev.mirim.domain.User;
|
||||
import dev.awesomedev.mirim.repository.StudentProfileRepository;
|
||||
import dev.awesomedev.mirim.repository.UserRepository;
|
||||
import dev.awesomedev.mirim.web.dto.ProfileResponse;
|
||||
import dev.awesomedev.mirim.web.dto.ProfileUpdateRequest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* "내 정보 수정"의 규칙을 검증한다 — 수정이 실제로 반영되는가(managed 엔티티),
|
||||
* 이메일 중복 방어, 프로필 없는 계정(멘토) 처리.
|
||||
*/
|
||||
class StudentProfileServiceTest {
|
||||
|
||||
private StudentProfileRepository studentProfileRepository;
|
||||
private UserRepository userRepository;
|
||||
private StudentProfileService service;
|
||||
|
||||
private User student;
|
||||
private StudentProfile profile;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
studentProfileRepository = mock(StudentProfileRepository.class);
|
||||
userRepository = mock(UserRepository.class);
|
||||
service = new StudentProfileService(studentProfileRepository, userRepository,
|
||||
mock(TelegramNotifier.class));
|
||||
|
||||
student = new User("student1", "해시", "임동혁", "STUDENT", "DEV");
|
||||
ReflectionTestUtils.setField(student, "id", 3L);
|
||||
profile = new StudentProfile(student, "donghyoek.lim", LocalDate.of(2008, 2, 27),
|
||||
"010-6485-4121", "시흥시/정왕2동", "미림고", "뉴미디어SW",
|
||||
"차정순", "모", "010-4316-6756", Instant.now());
|
||||
}
|
||||
|
||||
private ProfileUpdateRequest request(String emailLocal, String address) {
|
||||
return new ProfileUpdateRequest("임동혁", emailLocal, LocalDate.of(2008, 2, 27),
|
||||
"010-6485-4121", address, "미림고", "뉴미디어SW",
|
||||
"차정순", "모", "010-4316-6756");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("수정하면 이메일·주소가 실제로 바뀐다 (트랜잭션 안 재조회 = managed)")
|
||||
void update_actually_persists() {
|
||||
when(userRepository.findById(3L)).thenReturn(Optional.of(student));
|
||||
when(studentProfileRepository.findByUser(student)).thenReturn(Optional.of(profile));
|
||||
|
||||
// 오타였던 이메일과 불완전한 주소를 고치는 시나리오 (실제 있었던 일!)
|
||||
ProfileResponse res = service.updateMyProfile(student,
|
||||
request("donghyeok.lim", "경기 시흥시 정왕2동 123, 45호"));
|
||||
|
||||
assertEquals("donghyeok.lim", profile.getCompanyEmailLocal());
|
||||
assertEquals("경기 시흥시 정왕2동 123, 45호", profile.getAddress());
|
||||
assertEquals("donghyeok.lim@awesomedev.dev", res.fullEmail());
|
||||
// 트랜잭션 안에서 다시 조회했는가 (detached 함정 회귀 방지)
|
||||
verify(userRepository).findById(3L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("이메일을 남이 쓰는 값으로 바꾸려 하면 409, 내 값 그대로면 통과")
|
||||
void update_checks_email_duplication_only_when_changed() {
|
||||
when(userRepository.findById(3L)).thenReturn(Optional.of(student));
|
||||
when(studentProfileRepository.findByUser(student)).thenReturn(Optional.of(profile));
|
||||
when(studentProfileRepository.existsByCompanyEmailLocal("jiyu.im")).thenReturn(true);
|
||||
|
||||
// 남이 쓰는 이메일 → 409
|
||||
ResponseStatusException ex = assertThrows(ResponseStatusException.class,
|
||||
() -> service.updateMyProfile(student, request("jiyu.im", "주소")));
|
||||
assertEquals(409, ex.getStatusCode().value());
|
||||
|
||||
// 내 이메일 그대로 → 중복 검사 없이 통과 (existsBy가 내 것 때문에 true여도 막히면 안 됨)
|
||||
assertDoesNotThrow(() -> service.updateMyProfile(student, request("donghyoek.lim", "주소")));
|
||||
verify(studentProfileRepository, never()).existsByCompanyEmailLocal("donghyoek.lim");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("프로필이 없는 계정(멘토)이 조회하면 404")
|
||||
void myProfile_throws_404_for_mentor() {
|
||||
User mentor = new User("mentor1", "해시", "멘토", "MENTOR", "DEV");
|
||||
when(studentProfileRepository.findByUser(mentor)).thenReturn(Optional.empty());
|
||||
|
||||
ResponseStatusException ex = assertThrows(ResponseStatusException.class,
|
||||
() -> service.myProfile(mentor));
|
||||
assertEquals(404, ex.getStatusCode().value());
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,7 @@ import DocViewerPage from './pages/DocViewerPage';
|
||||
import AssignmentsPage from './pages/AssignmentsPage';
|
||||
import SetupGuidePage from './pages/SetupGuidePage';
|
||||
import ChangePasswordPage from './pages/ChangePasswordPage';
|
||||
import MyProfilePage from './pages/MyProfilePage';
|
||||
import QuizPage from './pages/QuizPage';
|
||||
import CodingPage from './pages/CodingPage';
|
||||
import CodingProblemPage from './pages/CodingProblemPage';
|
||||
@ -89,6 +90,12 @@ function Layout() {
|
||||
이름과 역할은 다른 정보이니 역할은 배지로 분리한다 — 중복도 없어지고 폭도 준다. */}
|
||||
<span className="topnav-name">{user?.name}</span>
|
||||
{user?.role === 'MENTOR' && <span className="badge badge-primary">멘토</span>}
|
||||
{/* 내 정보 — 학생만. (멘토는 인적사항이 없어서 숨긴다) */}
|
||||
{user?.role === 'STUDENT' && (
|
||||
<NavLink to="/profile" className="btn btn-ghost" title="내 정보">
|
||||
👤
|
||||
</NavLink>
|
||||
)}
|
||||
{/* 비밀번호 변경 — 처음 받은 비밀번호를 바꿀 수 있는 통로. 눈에 띄는 곳에 둔다. */}
|
||||
<NavLink to="/change-password" className="btn btn-ghost" title="비밀번호 변경">
|
||||
🔑
|
||||
@ -139,6 +146,7 @@ export default function App() {
|
||||
<Route path="/learn/:slug/quiz" element={<QuizPage />} />
|
||||
<Route path="/setup" element={<SetupGuidePage />} />
|
||||
<Route path="/change-password" element={<ChangePasswordPage />} />
|
||||
<Route path="/profile" element={<MyProfilePage />} />
|
||||
<Route path="/docs" element={<DocsPage />} />
|
||||
<Route path="/docs/:slug" element={<DocViewerPage />} />
|
||||
<Route path="/assignments" element={<AssignmentsPage />} />
|
||||
|
||||
190
frontend/src/pages/MyProfilePage.jsx
Normal file
190
frontend/src/pages/MyProfilePage.jsx
Normal file
@ -0,0 +1,190 @@
|
||||
// 이 파일이 하는 일: "내 정보" 화면 — 가입 때 적은 인적사항을 확인하고 직접 고친다.
|
||||
// (주소 이사, 연락처 변경, 이메일 아이디 오타 교정 등. 아이디·트랙은 여기서 못 바꾼다.)
|
||||
import { useEffect, useState } from 'react';
|
||||
import client from '../api/client';
|
||||
|
||||
const COMPANY_DOMAIN = 'awesomedev.dev';
|
||||
|
||||
// 입력 한 줄 부품 — 컴포넌트 "밖"에 정의한다.
|
||||
// (안에 정의하면 글자마다 리마운트되어 포커스가 풀리는 버그 — SignupPage의 교훈 참고)
|
||||
function Field({ label, k, type = 'text', placeholder, value, onChange }) {
|
||||
return (
|
||||
<div className="field">
|
||||
<label htmlFor={`pf-${k}`}>{label}</label>
|
||||
<input
|
||||
id={`pf-${k}`}
|
||||
className="input"
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onChange(k, e.target.value)}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MyProfilePage() {
|
||||
const [form, setForm] = useState(null); // 서버에서 받아와 채우기 전에는 null
|
||||
const [username, setUsername] = useState('');
|
||||
const [track, setTrack] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [saved, setSaved] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
client
|
||||
.get('/profile/me')
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
const p = res.data;
|
||||
setUsername(p.username);
|
||||
setTrack(p.track);
|
||||
// 수정 가능한 필드만 폼 상태로 옮긴다
|
||||
setForm({
|
||||
name: p.name, companyEmailLocal: p.companyEmailLocal, birthDate: p.birthDate,
|
||||
phone: p.phone, address: p.address, school: p.school, major: p.major,
|
||||
emergencyName: p.emergencyName, emergencyRelation: p.emergencyRelation,
|
||||
emergencyPhone: p.emergencyPhone,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setError(err.response?.status === 404
|
||||
? '멘토 계정에는 인적사항이 없어요.'
|
||||
: '정보를 불러오지 못했어요.');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
function set(key, value) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
setSaved(''); // 수정을 시작하면 이전 저장 안내는 지운다
|
||||
}
|
||||
|
||||
// 가입 폼과 같은 규칙 — 서버 검증(ProfileUpdateRequest)과 일치시킨다.
|
||||
function validate() {
|
||||
if (!form.name.trim()) return '이름을 입력해 주세요.';
|
||||
if (!/^[a-z0-9._-]{2,40}$/.test(form.companyEmailLocal))
|
||||
return '회사 이메일 아이디는 소문자·숫자·. _ - 만 쓸 수 있어요.';
|
||||
if (!form.birthDate) return '생년월일을 입력해 주세요.';
|
||||
if (form.birthDate >= new Date().toISOString().slice(0, 10))
|
||||
return '생년월일을 다시 확인해 주세요 — 오늘 이전 날짜여야 해요.';
|
||||
if (!/^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$/.test(form.phone)) return '연락처 형식을 확인해 주세요.';
|
||||
if (!form.address.trim()) return '주소를 입력해 주세요. (시/군/구부터 상세주소까지)';
|
||||
if (!form.school.trim()) return '학교를 입력해 주세요.';
|
||||
if (!form.major.trim()) return '전공을 입력해 주세요.';
|
||||
if (!form.emergencyName.trim()) return '비상 연락처 이름을 입력해 주세요.';
|
||||
if (!form.emergencyRelation.trim()) return '비상 연락처 관계를 입력해 주세요.';
|
||||
if (!/^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$/.test(form.emergencyPhone))
|
||||
return '비상 연락처 전화번호 형식을 확인해 주세요.';
|
||||
return '';
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSaved('');
|
||||
const message = validate();
|
||||
if (message) {
|
||||
setError(message);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await client.put('/profile/me', {
|
||||
...form,
|
||||
name: form.name.trim(),
|
||||
companyEmailLocal: form.companyEmailLocal.trim(),
|
||||
phone: form.phone.trim(),
|
||||
address: form.address.trim(),
|
||||
school: form.school.trim(),
|
||||
major: form.major.trim(),
|
||||
emergencyName: form.emergencyName.trim(),
|
||||
emergencyRelation: form.emergencyRelation.trim(),
|
||||
emergencyPhone: form.emergencyPhone.trim(),
|
||||
});
|
||||
setSaved('저장했어요! ✅');
|
||||
setForm((prev) => ({ ...prev, companyEmailLocal: res.data.companyEmailLocal }));
|
||||
} catch (err) {
|
||||
// 서버가 담아 준 사유(중복 이메일 등)를 그대로 보여준다
|
||||
setError(err.response?.data?.message || '저장하지 못했어요. 잠시 후 다시 시도해 주세요.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <p className="empty">불러오는 중…</p>;
|
||||
if (!form) return <p className="error-text">{error}</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="hero compact">
|
||||
<div className="eyebrow">My Info</div>
|
||||
<h1>내 정보</h1>
|
||||
<p>
|
||||
가입 때 적은 인적사항이에요. 이사·번호 변경·오타가 있으면 직접 고칠 수 있어요.
|
||||
경리 서류에 쓰이니 <strong>주소는 시/군/구부터 상세주소까지</strong> 적어 주세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ maxWidth: 560 }}>
|
||||
{/* 바꿀 수 없는 것들 — 왜 안 되는지도 알려준다 */}
|
||||
<div className="chip-row" style={{ marginBottom: 16 }}>
|
||||
<span className="chip">아이디: {username} (변경 불가)</span>
|
||||
<span className="chip">트랙: {track === 'DESIGN' ? '디자인' : '개발'} (변경은 멘토에게)</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Field label="이름" k="name" value={form.name} onChange={set} />
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="pf-companyEmailLocal">회사 이메일</label>
|
||||
<div style={{ display: 'flex', alignItems: 'stretch', gap: 6 }}>
|
||||
<input
|
||||
id="pf-companyEmailLocal"
|
||||
className="input"
|
||||
value={form.companyEmailLocal}
|
||||
onChange={(e) => set('companyEmailLocal', e.target.value.toLowerCase())}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span className="badge badge-primary" style={{ display: 'flex', alignItems: 'center', fontSize: 14 }}>
|
||||
@{COMPANY_DOMAIN}
|
||||
</span>
|
||||
</div>
|
||||
<p className="muted" style={{ fontSize: 12.5, marginTop: 4 }}>
|
||||
영문 <strong>이름.성</strong> 형식 권장 · 철자를 꼭 확인하세요 — 이대로 실제 메일 계정이 만들어져요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Field label="생년월일" k="birthDate" type="date" value={form.birthDate} onChange={set} />
|
||||
<Field label="연락처" k="phone" placeholder="010-1234-5678" value={form.phone} onChange={set} />
|
||||
<Field label="주소 (시/군/구부터 상세주소까지)" k="address"
|
||||
placeholder="예: 서울 동작구 상도로17길 14-2, 501호" value={form.address} onChange={set} />
|
||||
<Field label="학교" k="school" value={form.school} onChange={set} />
|
||||
<Field label="전공(학과)" k="major" value={form.major} onChange={set} />
|
||||
|
||||
<div className="section-title">비상 연락처</div>
|
||||
<Field label="이름" k="emergencyName" value={form.emergencyName} onChange={set} />
|
||||
<Field label="관계 (예: 부, 모)" k="emergencyRelation" value={form.emergencyRelation} onChange={set} />
|
||||
<Field label="전화번호" k="emergencyPhone" placeholder="010-1234-5678" value={form.emergencyPhone} onChange={set} />
|
||||
|
||||
{error && <p className="error-text" style={{ marginTop: 10 }}>{error}</p>}
|
||||
{saved && <p style={{ color: 'var(--teal)', fontSize: 14, marginTop: 10 }}>{saved}</p>}
|
||||
|
||||
<button className="btn btn-primary" type="submit" disabled={saving}
|
||||
style={{ marginTop: 12 }}>
|
||||
{saving ? '저장 중…' : '저장하기'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user