diff --git a/backend/src/main/java/dev/awesomedev/mirim/domain/StudentProfile.java b/backend/src/main/java/dev/awesomedev/mirim/domain/StudentProfile.java index 9f8b5a4..5923a43 100644 --- a/backend/src/main/java/dev/awesomedev/mirim/domain/StudentProfile.java +++ b/backend/src/main/java/dev/awesomedev/mirim/domain/StudentProfile.java @@ -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"; diff --git a/backend/src/main/java/dev/awesomedev/mirim/domain/User.java b/backend/src/main/java/dev/awesomedev/mirim/domain/User.java index 9eda75d..9df3e11 100644 --- a/backend/src/main/java/dev/awesomedev/mirim/domain/User.java +++ b/backend/src/main/java/dev/awesomedev/mirim/domain/User.java @@ -65,6 +65,11 @@ public class User { * 아예 없으면, 실수로라도 그런 코드를 쓸 수 없다(엔티티가 스스로 규칙을 지킨다). * 여기에는 반드시 "이미 해시된 값"만 들어와야 한다 — 인코딩은 서비스 계층의 책임이다. */ + /** 이름 변경 (내 정보 수정에서 오타 교정용). */ + public void rename(String newName) { + this.name = newName; + } + public void changePassword(String newPasswordHash) { this.passwordHash = newPasswordHash; } diff --git a/backend/src/main/java/dev/awesomedev/mirim/service/StudentProfileService.java b/backend/src/main/java/dev/awesomedev/mirim/service/StudentProfileService.java index a6f3d0c..99119da 100644 --- a/backend/src/main/java/dev/awesomedev/mirim/service/StudentProfileService.java +++ b/backend/src/main/java/dev/awesomedev/mirim/service/StudentProfileService.java @@ -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) diff --git a/backend/src/main/java/dev/awesomedev/mirim/web/ProfileController.java b/backend/src/main/java/dev/awesomedev/mirim/web/ProfileController.java new file mode 100644 index 0000000..c607e85 --- /dev/null +++ b/backend/src/main/java/dev/awesomedev/mirim/web/ProfileController.java @@ -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); + } +} diff --git a/backend/src/main/java/dev/awesomedev/mirim/web/dto/ProfileResponse.java b/backend/src/main/java/dev/awesomedev/mirim/web/dto/ProfileResponse.java new file mode 100644 index 0000000..8d7b403 --- /dev/null +++ b/backend/src/main/java/dev/awesomedev/mirim/web/dto/ProfileResponse.java @@ -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()); + } +} diff --git a/backend/src/main/java/dev/awesomedev/mirim/web/dto/ProfileUpdateRequest.java b/backend/src/main/java/dev/awesomedev/mirim/web/dto/ProfileUpdateRequest.java new file mode 100644 index 0000000..0823596 --- /dev/null +++ b/backend/src/main/java/dev/awesomedev/mirim/web/dto/ProfileUpdateRequest.java @@ -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 +) { +} diff --git a/backend/src/test/java/dev/awesomedev/mirim/service/StudentProfileServiceTest.java b/backend/src/test/java/dev/awesomedev/mirim/service/StudentProfileServiceTest.java new file mode 100644 index 0000000..45ac4fd --- /dev/null +++ b/backend/src/test/java/dev/awesomedev/mirim/service/StudentProfileServiceTest.java @@ -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()); + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7edc46f..d2f82bc 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { 이름과 역할은 다른 정보이니 역할은 배지로 분리한다 — 중복도 없어지고 폭도 준다. */} {user?.name} {user?.role === 'MENTOR' && 멘토} + {/* 내 정보 — 학생만. (멘토는 인적사항이 없어서 숨긴다) */} + {user?.role === 'STUDENT' && ( + + 👤 + + )} {/* 비밀번호 변경 — 처음 받은 비밀번호를 바꿀 수 있는 통로. 눈에 띄는 곳에 둔다. */} 🔑 @@ -139,6 +146,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/MyProfilePage.jsx b/frontend/src/pages/MyProfilePage.jsx new file mode 100644 index 0000000..a7b33f2 --- /dev/null +++ b/frontend/src/pages/MyProfilePage.jsx @@ -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 ( + + {label} + onChange(k, e.target.value)} + placeholder={placeholder} + /> + + ); +} + +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 불러오는 중…; + if (!form) return {error}; + + return ( + + + My Info + 내 정보 + + 가입 때 적은 인적사항이에요. 이사·번호 변경·오타가 있으면 직접 고칠 수 있어요. + 경리 서류에 쓰이니 주소는 시/군/구부터 상세주소까지 적어 주세요. + + + + + {/* 바꿀 수 없는 것들 — 왜 안 되는지도 알려준다 */} + + 아이디: {username} (변경 불가) + 트랙: {track === 'DESIGN' ? '디자인' : '개발'} (변경은 멘토에게) + + + + + + + 회사 이메일 + + set('companyEmailLocal', e.target.value.toLowerCase())} + style={{ flex: 1 }} + /> + + @{COMPANY_DOMAIN} + + + + 영문 이름.성 형식 권장 · 철자를 꼭 확인하세요 — 이대로 실제 메일 계정이 만들어져요. + + + + + + + + + + 비상 연락처 + + + + + {error && {error}} + {saved && {saved}} + + + {saving ? '저장 중…' : '저장하기'} + + + + + ); +}
불러오는 중…
{error}
+ 가입 때 적은 인적사항이에요. 이사·번호 변경·오타가 있으면 직접 고칠 수 있어요. + 경리 서류에 쓰이니 주소는 시/군/구부터 상세주소까지 적어 주세요. +
+ 영문 이름.성 형식 권장 · 철자를 꼭 확인하세요 — 이대로 실제 메일 계정이 만들어져요. +
{saved}