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:
AWESOMEDEV 2026-07-17 11:52:04 +09:00
parent 0336213ca2
commit f557d7a189
9 changed files with 528 additions and 1 deletions

View File

@ -102,6 +102,25 @@ public class StudentProfile {
this.privacyConsentAt = privacyConsentAt; 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"; public static final String COMPANY_DOMAIN = "awesomedev.dev";

View File

@ -65,6 +65,11 @@ public class User {
* 아예 없으면, 실수로라도 그런 코드를 없다(엔티티가 스스로 규칙을 지킨다). * 아예 없으면, 실수로라도 그런 코드를 없다(엔티티가 스스로 규칙을 지킨다).
* 여기에는 반드시 "이미 해시된 값" 들어와야 한다 인코딩은 서비스 계층의 책임이다. * 여기에는 반드시 "이미 해시된 값" 들어와야 한다 인코딩은 서비스 계층의 책임이다.
*/ */
/** 이름 변경 (내 정보 수정에서 오타 교정용). */
public void rename(String newName) {
this.name = newName;
}
public void changePassword(String newPasswordHash) { public void changePassword(String newPasswordHash) {
this.passwordHash = newPasswordHash; this.passwordHash = newPasswordHash;
} }

View File

@ -1,9 +1,15 @@
package dev.awesomedev.mirim.service; package dev.awesomedev.mirim.service;
import dev.awesomedev.mirim.domain.StudentProfile; import dev.awesomedev.mirim.domain.StudentProfile;
import dev.awesomedev.mirim.domain.User;
import dev.awesomedev.mirim.repository.StudentProfileRepository; 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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.List; import java.util.List;
@ -19,9 +25,61 @@ import java.util.List;
public class StudentProfileService { public class StudentProfileService {
private final StudentProfileRepository studentProfileRepository; 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.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) @Transactional(readOnly = true)

View File

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

View File

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

View File

@ -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
) {
}

View File

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

View File

@ -18,6 +18,7 @@ import DocViewerPage from './pages/DocViewerPage';
import AssignmentsPage from './pages/AssignmentsPage'; import AssignmentsPage from './pages/AssignmentsPage';
import SetupGuidePage from './pages/SetupGuidePage'; import SetupGuidePage from './pages/SetupGuidePage';
import ChangePasswordPage from './pages/ChangePasswordPage'; import ChangePasswordPage from './pages/ChangePasswordPage';
import MyProfilePage from './pages/MyProfilePage';
import QuizPage from './pages/QuizPage'; import QuizPage from './pages/QuizPage';
import CodingPage from './pages/CodingPage'; import CodingPage from './pages/CodingPage';
import CodingProblemPage from './pages/CodingProblemPage'; import CodingProblemPage from './pages/CodingProblemPage';
@ -89,6 +90,12 @@ function Layout() {
이름과 역할은 다른 정보이니 역할은 배지로 분리한다 중복도 없어지고 폭도 준다. */} 이름과 역할은 다른 정보이니 역할은 배지로 분리한다 중복도 없어지고 폭도 준다. */}
<span className="topnav-name">{user?.name}</span> <span className="topnav-name">{user?.name}</span>
{user?.role === 'MENTOR' && <span className="badge badge-primary">멘토</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="비밀번호 변경"> <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="/learn/:slug/quiz" element={<QuizPage />} />
<Route path="/setup" element={<SetupGuidePage />} /> <Route path="/setup" element={<SetupGuidePage />} />
<Route path="/change-password" element={<ChangePasswordPage />} /> <Route path="/change-password" element={<ChangePasswordPage />} />
<Route path="/profile" element={<MyProfilePage />} />
<Route path="/docs" element={<DocsPage />} /> <Route path="/docs" element={<DocsPage />} />
<Route path="/docs/:slug" element={<DocViewerPage />} /> <Route path="/docs/:slug" element={<DocViewerPage />} />
<Route path="/assignments" element={<AssignmentsPage />} /> <Route path="/assignments" element={<AssignmentsPage />} />

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