feat(homepage): 회원가입·로그인·SNS 로그인 + 관리자 회원관리
## 홈페이지 (프론트엔드) - MemberLogin.jsx: 회원가입/로그인 통합 페이지 + 카카오·네이버·구글 SNS 버튼 - MemberAuth.css: 인증 페이지 공통 스타일 - hooks/useMemberAuth.jsx: 회원 인증 상태 훅 + MemberOnly 컴포넌트 (회원 전용 잠금) - Header.jsx: 로그인/회원가입 버튼 + 로그인 시 이름/로그아웃 표시 - Contact.jsx: 문의 상담 신청 → 회원 전용 (MemberOnly 적용) - App.jsx: /login, /register 라우트 추가 ## 관리자 (Admin) - AdminMember.jsx: 회원 목록/검색/상태변경/삭제 페이지 - AdminLayout.jsx: '회원 관리' 메뉴 추가 - App.jsx: /admin/members 라우트 추가 ## 백엔드 (Spring Boot) - Member.java: 회원 엔티티 (id/name/email/password/phone/company/role/active) - MemberRepository.java: 이메일 조회·중복확인·키워드 검색 - MemberController.java: 회원가입·이메일 중복확인·로그인·SNS 로그인·내 정보 CRUD - AdminController.java: 회원관리 API (목록/상세/상태변경/삭제) + 대시보드에 회원 수 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
880d9cbcfc
commit
c86bd72830
@ -19,6 +19,7 @@ public class AdminController {
|
|||||||
private final NewsRepository newsRepo;
|
private final NewsRepository newsRepo;
|
||||||
private final InquiryRepository inquiryRepo;
|
private final InquiryRepository inquiryRepo;
|
||||||
private final RecruitRepository recruitRepo;
|
private final RecruitRepository recruitRepo;
|
||||||
|
private final MemberRepository memberRepo;
|
||||||
private final JwtUtil jwtUtil;
|
private final JwtUtil jwtUtil;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
@ -47,6 +48,8 @@ public class AdminController {
|
|||||||
stats.put("pendingInquiries", inquiryRepo.countByStatus("PENDING"));
|
stats.put("pendingInquiries", inquiryRepo.countByStatus("PENDING"));
|
||||||
stats.put("totalRecruits", recruitRepo.count());
|
stats.put("totalRecruits", recruitRepo.count());
|
||||||
stats.put("activeRecruits", recruitRepo.countByActiveTrue());
|
stats.put("activeRecruits", recruitRepo.countByActiveTrue());
|
||||||
|
stats.put("totalMembers", memberRepo.count());
|
||||||
|
stats.put("activeMembers", memberRepo.countByActiveTrue());
|
||||||
stats.put("recentInquiries", inquiryRepo.findTop5ByOrderByCreatedAtDesc());
|
stats.put("recentInquiries", inquiryRepo.findTop5ByOrderByCreatedAtDesc());
|
||||||
stats.put("recentNews", newsRepo.findTop5ByOrderByCreatedAtDesc());
|
stats.put("recentNews", newsRepo.findTop5ByOrderByCreatedAtDesc());
|
||||||
return ResponseEntity.ok(stats);
|
return ResponseEntity.ok(stats);
|
||||||
@ -164,6 +167,43 @@ public class AdminController {
|
|||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 회원 관리 ────────────────────────────────────────────
|
||||||
|
@GetMapping("/members")
|
||||||
|
public ResponseEntity<?> listMembers(
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size,
|
||||||
|
@RequestParam(required = false) String keyword) {
|
||||||
|
Pageable pg = PageRequest.of(page, size, Sort.by("createdAt").descending());
|
||||||
|
return ResponseEntity.ok(memberRepo.search(keyword, pg));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/members/{id}")
|
||||||
|
public ResponseEntity<?> getMember(@PathVariable Long id) {
|
||||||
|
return memberRepo.findById(id)
|
||||||
|
.map(ResponseEntity::ok)
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/members/{id}/status")
|
||||||
|
public ResponseEntity<?> toggleMemberStatus(@PathVariable Long id,
|
||||||
|
@RequestBody Map<String, Boolean> body) {
|
||||||
|
return memberRepo.findById(id).map(m -> {
|
||||||
|
m.setActive(body.getOrDefault("active", !m.isActive()));
|
||||||
|
memberRepo.save(m);
|
||||||
|
return ResponseEntity.ok(Map.of("id", m.getId(), "active", m.isActive()));
|
||||||
|
}).orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/members/{id}")
|
||||||
|
public ResponseEntity<?> deleteMember(@PathVariable Long id) {
|
||||||
|
if (!memberRepo.existsById(id)) return ResponseEntity.notFound().build();
|
||||||
|
memberRepo.deleteById(id);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 대시보드 통계에 회원 수 추가 ─────────────────────────
|
||||||
|
// (기존 dashboard 메서드에 totalMembers 추가는 별도 수정)
|
||||||
|
|
||||||
// ── 비밀번호 변경 ────────────────────────────────────────
|
// ── 비밀번호 변경 ────────────────────────────────────────
|
||||||
@PutMapping("/password")
|
@PutMapping("/password")
|
||||||
public ResponseEntity<?> changePassword(
|
public ResponseEntity<?> changePassword(
|
||||||
|
|||||||
@ -0,0 +1,149 @@
|
|||||||
|
package kr.co.zioinfo.web.controller;
|
||||||
|
|
||||||
|
import kr.co.zioinfo.web.model.Member;
|
||||||
|
import kr.co.zioinfo.web.repository.MemberRepository;
|
||||||
|
import kr.co.zioinfo.web.security.JwtUtil;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/members")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MemberController {
|
||||||
|
|
||||||
|
private final MemberRepository memberRepo;
|
||||||
|
private final JwtUtil jwtUtil;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
// ── 회원가입 ─────────────────────────────────────────────────
|
||||||
|
@PostMapping("/register")
|
||||||
|
public ResponseEntity<?> register(@RequestBody Map<String, String> body) {
|
||||||
|
String email = body.get("email");
|
||||||
|
String name = body.get("name");
|
||||||
|
String pw = body.get("password");
|
||||||
|
String phone = body.getOrDefault("phone", "");
|
||||||
|
String company = body.getOrDefault("company", "");
|
||||||
|
|
||||||
|
if (email == null || name == null || pw == null)
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", "이름, 이메일, 비밀번호는 필수입니다."));
|
||||||
|
|
||||||
|
if (memberRepo.existsByEmail(email))
|
||||||
|
return ResponseEntity.status(409).body(Map.of("message", "이미 가입된 이메일입니다."));
|
||||||
|
|
||||||
|
if (pw.length() < 8)
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", "비밀번호는 8자 이상이어야 합니다."));
|
||||||
|
|
||||||
|
Member m = Member.builder()
|
||||||
|
.name(name)
|
||||||
|
.email(email)
|
||||||
|
.password(passwordEncoder.encode(pw))
|
||||||
|
.phone(phone)
|
||||||
|
.company(company)
|
||||||
|
.role("USER")
|
||||||
|
.active(true)
|
||||||
|
.build();
|
||||||
|
memberRepo.save(m);
|
||||||
|
|
||||||
|
String token = jwtUtil.generate("member:" + m.getEmail());
|
||||||
|
return ResponseEntity.ok(buildResponse(m, token));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 이메일 중복 확인 ─────────────────────────────────────────
|
||||||
|
@GetMapping("/check-email")
|
||||||
|
public ResponseEntity<?> checkEmail(@RequestParam String email) {
|
||||||
|
return ResponseEntity.ok(Map.of("exists", memberRepo.existsByEmail(email)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 로그인 ───────────────────────────────────────────────────
|
||||||
|
@PostMapping("/login")
|
||||||
|
public ResponseEntity<?> login(@RequestBody Map<String, String> body) {
|
||||||
|
String email = body.get("email");
|
||||||
|
String pw = body.get("password");
|
||||||
|
return memberRepo.findByEmail(email)
|
||||||
|
.filter(m -> m.isActive() && passwordEncoder.matches(pw, m.getPassword()))
|
||||||
|
.map(m -> ResponseEntity.ok(buildResponse(m, jwtUtil.generate("member:" + m.getEmail()))))
|
||||||
|
.orElse(ResponseEntity.status(401).body(Map.of("message", "이메일 또는 비밀번호가 올바르지 않습니다.")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SNS 로그인 (카카오·네이버·구글) ─────────────────────────
|
||||||
|
@PostMapping("/social-login")
|
||||||
|
public ResponseEntity<?> socialLogin(@RequestBody Map<String, String> body) {
|
||||||
|
String provider = body.get("provider"); // kakao | naver | google
|
||||||
|
String email = body.get("email");
|
||||||
|
String name = body.getOrDefault("name", "회원");
|
||||||
|
String socialId = body.getOrDefault("id", "");
|
||||||
|
|
||||||
|
if (email == null || email.isBlank())
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", "SNS 이메일 정보가 없습니다."));
|
||||||
|
|
||||||
|
// 기존 회원이면 로그인, 없으면 자동 가입
|
||||||
|
Member m = memberRepo.findByEmail(email).orElseGet(() ->
|
||||||
|
memberRepo.save(Member.builder()
|
||||||
|
.name(name)
|
||||||
|
.email(email)
|
||||||
|
.password(passwordEncoder.encode(UUID.randomUUID().toString()))
|
||||||
|
.role("USER")
|
||||||
|
.active(true)
|
||||||
|
.build())
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!m.isActive())
|
||||||
|
return ResponseEntity.status(403).body(Map.of("message", "비활성화된 계정입니다."));
|
||||||
|
|
||||||
|
String token = jwtUtil.generate("member:" + m.getEmail());
|
||||||
|
Map<String, Object> res = buildResponse(m, token);
|
||||||
|
res.put("provider", provider);
|
||||||
|
return ResponseEntity.ok(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 내 정보 조회 ─────────────────────────────────────────────
|
||||||
|
@GetMapping("/me")
|
||||||
|
public ResponseEntity<?> getMe(@RequestHeader("Authorization") String authHeader) {
|
||||||
|
return extractEmail(authHeader)
|
||||||
|
.flatMap(memberRepo::findByEmail)
|
||||||
|
.map(m -> ResponseEntity.ok(buildResponse(m, null)))
|
||||||
|
.orElse(ResponseEntity.status(401).body(Map.of("message", "인증이 필요합니다.")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 내 정보 수정 ─────────────────────────────────────────────
|
||||||
|
@PutMapping("/me")
|
||||||
|
public ResponseEntity<?> updateMe(@RequestHeader("Authorization") String authHeader,
|
||||||
|
@RequestBody Map<String, String> body) {
|
||||||
|
return extractEmail(authHeader)
|
||||||
|
.flatMap(memberRepo::findByEmail)
|
||||||
|
.map(m -> {
|
||||||
|
if (body.containsKey("name")) m.setName(body.get("name"));
|
||||||
|
if (body.containsKey("phone")) m.setPhone(body.get("phone"));
|
||||||
|
if (body.containsKey("company")) m.setCompany(body.get("company"));
|
||||||
|
if (body.containsKey("password") && body.get("password").length() >= 8)
|
||||||
|
m.setPassword(passwordEncoder.encode(body.get("password")));
|
||||||
|
memberRepo.save(m);
|
||||||
|
return ResponseEntity.ok(buildResponse(m, null));
|
||||||
|
})
|
||||||
|
.orElse(ResponseEntity.status(401).body(Map.of("message", "인증이 필요합니다.")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 내부 유틸 ────────────────────────────────────────────────
|
||||||
|
private Optional<String> extractEmail(String authHeader) {
|
||||||
|
if (authHeader == null || !authHeader.startsWith("Bearer ")) return Optional.empty();
|
||||||
|
String subject = jwtUtil.extractUsername(authHeader.substring(7));
|
||||||
|
if (subject == null || !subject.startsWith("member:")) return Optional.empty();
|
||||||
|
return Optional.of(subject.substring(7));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> buildResponse(Member m, String token) {
|
||||||
|
Map<String, Object> r = new LinkedHashMap<>();
|
||||||
|
r.put("id", m.getId());
|
||||||
|
r.put("name", m.getName());
|
||||||
|
r.put("email", m.getEmail());
|
||||||
|
r.put("phone", m.getPhone());
|
||||||
|
r.put("company", m.getCompany());
|
||||||
|
r.put("role", m.getRole());
|
||||||
|
r.put("createdAt", m.getCreatedAt());
|
||||||
|
if (token != null) r.put("token", token);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
backend/src/main/java/kr/co/zioinfo/web/model/Member.java
Normal file
41
backend/src/main/java/kr/co/zioinfo/web/model/Member.java
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
package kr.co.zioinfo.web.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import jakarta.validation.constraints.*;
|
||||||
|
import lombok.*;
|
||||||
|
import org.springframework.data.annotation.CreatedDate;
|
||||||
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity @Table(name = "member")
|
||||||
|
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
|
||||||
|
@EntityListeners(AuditingEntityListener.class)
|
||||||
|
public class Member {
|
||||||
|
|
||||||
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@NotBlank @Column(nullable = false, length = 50)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@NotBlank @Email @Column(nullable = false, unique = true, length = 100)
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@NotBlank @Column(nullable = false)
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@Column(length = 20)
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
@Column(length = 100)
|
||||||
|
private String company; // 소속 회사/기관 (선택)
|
||||||
|
|
||||||
|
@Column(length = 30)
|
||||||
|
private String role = "USER"; // USER | ADMIN
|
||||||
|
|
||||||
|
private boolean active = true;
|
||||||
|
|
||||||
|
@CreatedDate
|
||||||
|
@Column(updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package kr.co.zioinfo.web.repository;
|
||||||
|
|
||||||
|
import kr.co.zioinfo.web.model.Member;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface MemberRepository extends JpaRepository<Member, Long> {
|
||||||
|
|
||||||
|
Optional<Member> findByEmail(String email);
|
||||||
|
|
||||||
|
boolean existsByEmail(String email);
|
||||||
|
|
||||||
|
@Query("SELECT m FROM Member m WHERE " +
|
||||||
|
"(:keyword IS NULL OR m.name LIKE %:keyword% OR m.email LIKE %:keyword% OR m.company LIKE %:keyword%)")
|
||||||
|
Page<Member> search(@Param("keyword") String keyword, Pageable pageable);
|
||||||
|
|
||||||
|
long countByActiveTrue();
|
||||||
|
}
|
||||||
@ -14,6 +14,9 @@ const NewsPage = lazy(() => import('./pages/NewsPage'));
|
|||||||
const Recruit = lazy(() => import('./pages/Recruit'));
|
const Recruit = lazy(() => import('./pages/Recruit'));
|
||||||
const NotFound = lazy(() => import('./pages/NotFound'));
|
const NotFound = lazy(() => import('./pages/NotFound'));
|
||||||
|
|
||||||
|
// Member Auth
|
||||||
|
const MemberLogin = lazy(() => import('./pages/MemberLogin'));
|
||||||
|
|
||||||
// Admin
|
// Admin
|
||||||
const AdminLogin = lazy(() => import('./pages/admin/AdminLogin'));
|
const AdminLogin = lazy(() => import('./pages/admin/AdminLogin'));
|
||||||
const AdminLayout = lazy(() => import('./pages/admin/AdminLayout'));
|
const AdminLayout = lazy(() => import('./pages/admin/AdminLayout'));
|
||||||
@ -22,6 +25,7 @@ const AdminNews = lazy(() => import('./pages/admin/AdminNews'));
|
|||||||
const AdminInquiry = lazy(() => import('./pages/admin/AdminInquiry'));
|
const AdminInquiry = lazy(() => import('./pages/admin/AdminInquiry'));
|
||||||
const AdminRecruit = lazy(() => import('./pages/admin/AdminRecruit'));
|
const AdminRecruit = lazy(() => import('./pages/admin/AdminRecruit'));
|
||||||
const AdminSettings = lazy(() => import('./pages/admin/AdminSettings'));
|
const AdminSettings = lazy(() => import('./pages/admin/AdminSettings'));
|
||||||
|
const AdminMember = lazy(() => import('./pages/admin/AdminMember'));
|
||||||
|
|
||||||
function Loading() {
|
function Loading() {
|
||||||
return (
|
return (
|
||||||
@ -57,6 +61,7 @@ export default function App() {
|
|||||||
<Route path="news" element={<AdminNews />} />
|
<Route path="news" element={<AdminNews />} />
|
||||||
<Route path="inquiries" element={<AdminInquiry />} />
|
<Route path="inquiries" element={<AdminInquiry />} />
|
||||||
<Route path="recruit" element={<AdminRecruit />} />
|
<Route path="recruit" element={<AdminRecruit />} />
|
||||||
|
<Route path="members" element={<AdminMember />} />
|
||||||
<Route path="settings" element={<AdminSettings />} />
|
<Route path="settings" element={<AdminSettings />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/admin/login" replace />} />
|
<Route path="*" element={<Navigate to="/admin/login" replace />} />
|
||||||
@ -77,6 +82,8 @@ export default function App() {
|
|||||||
<Route path="/support/*" element={<Support />} />
|
<Route path="/support/*" element={<Support />} />
|
||||||
<Route path="/recruit/*" element={<Recruit />} />
|
<Route path="/recruit/*" element={<Recruit />} />
|
||||||
<Route path="/news/*" element={<NewsPage />} />
|
<Route path="/news/*" element={<NewsPage />} />
|
||||||
|
<Route path="/login" element={<MemberLogin />} />
|
||||||
|
<Route path="/register" element={<MemberLogin />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</PublicLayout>
|
</PublicLayout>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import './Header.css';
|
import './Header.css';
|
||||||
|
|
||||||
const MENU = [
|
const MENU = [
|
||||||
@ -59,7 +59,27 @@ export default function Header() {
|
|||||||
const [scrolled, setScrolled] = useState(false);
|
const [scrolled, setScrolled] = useState(false);
|
||||||
const [activeMenu, setActiveMenu] = useState(null);
|
const [activeMenu, setActiveMenu] = useState(null);
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const [member, setMember] = useState(null);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// 로그인 상태 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
const sync = () => {
|
||||||
|
const u = localStorage.getItem('member_user');
|
||||||
|
setMember(u ? JSON.parse(u) : null);
|
||||||
|
};
|
||||||
|
sync();
|
||||||
|
window.addEventListener('storage', sync);
|
||||||
|
return () => window.removeEventListener('storage', sync);
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem('member_token');
|
||||||
|
localStorage.removeItem('member_user');
|
||||||
|
setMember(null);
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onScroll = () => setScrolled(window.scrollY > 60);
|
const onScroll = () => setScrolled(window.scrollY > 60);
|
||||||
@ -119,10 +139,25 @@ export default function Header() {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* 문의 버튼 */}
|
{/* 우측 버튼 영역 */}
|
||||||
<Link to="/support/contact" className="btn btn-primary btn-sm header-cta">
|
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
|
||||||
문의하기
|
<Link to="/support/contact" className="btn btn-outline btn-sm">문의하기</Link>
|
||||||
</Link>
|
{member ? (
|
||||||
|
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
|
||||||
|
<span style={{ fontSize:13, color:'var(--gray-600)', maxWidth:100,
|
||||||
|
overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
|
||||||
|
{member.name}님
|
||||||
|
</span>
|
||||||
|
<button onClick={logout}
|
||||||
|
style={{ padding:'6px 14px', background:'none', border:'1px solid #e2e8f0',
|
||||||
|
borderRadius:8, fontSize:12, color:'#64748b', cursor:'pointer' }}>
|
||||||
|
로그아웃
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Link to="/login" className="btn btn-primary btn-sm">로그인</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 햄버거 (모바일) */}
|
{/* 햄버거 (모바일) */}
|
||||||
<button className="hamburger" aria-label="모바일 메뉴"
|
<button className="hamburger" aria-label="모바일 메뉴"
|
||||||
@ -148,9 +183,13 @@ export default function Header() {
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
))}
|
))}
|
||||||
<Link to="/support/contact" className="btn btn-primary" style={{margin:'16px'}}>
|
<div style={{ display:'flex', gap:8, margin:'16px' }}>
|
||||||
문의하기
|
<Link to="/support/contact" className="btn btn-outline" style={{ flex:1 }}>문의하기</Link>
|
||||||
</Link>
|
{member
|
||||||
|
? <button onClick={logout} className="btn btn-primary" style={{ flex:1 }}>로그아웃</button>
|
||||||
|
: <Link to="/login" className="btn btn-primary" style={{ flex:1 }}>로그인 / 가입</Link>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
83
frontend/src/hooks/useMemberAuth.jsx
Normal file
83
frontend/src/hooks/useMemberAuth.jsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 인증 상태 관리 훅.
|
||||||
|
* - isLoggedIn: 로그인 여부
|
||||||
|
* - member: 회원 정보
|
||||||
|
* - requireLogin(redirectPath): 비회원이면 로그인 페이지로 이동
|
||||||
|
*/
|
||||||
|
export function useMemberAuth() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [member, setMember] = useState(null);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('member_token');
|
||||||
|
const user = localStorage.getItem('member_user');
|
||||||
|
if (token && user) {
|
||||||
|
try { setMember(JSON.parse(user)); } catch { /* 손상된 데이터 무시 */ }
|
||||||
|
}
|
||||||
|
setLoaded(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem('member_token');
|
||||||
|
localStorage.removeItem('member_user');
|
||||||
|
setMember(null);
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
const requireLogin = (redirectPath = window.location.pathname) => {
|
||||||
|
if (!member) {
|
||||||
|
navigate(`/login?redirect=${encodeURIComponent(redirectPath)}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
member,
|
||||||
|
isLoggedIn: !!member,
|
||||||
|
loaded,
|
||||||
|
logout,
|
||||||
|
requireLogin,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원 전용 접근 보호 컴포넌트.
|
||||||
|
* 비로그인 시 잠금 오버레이 표시.
|
||||||
|
*
|
||||||
|
* 사용:
|
||||||
|
* <MemberOnly feature="상담 신청">
|
||||||
|
* <ConsultForm />
|
||||||
|
* </MemberOnly>
|
||||||
|
*/
|
||||||
|
export function MemberOnly({ children, feature = '이 기능' }) {
|
||||||
|
const { isLoggedIn, loaded } = useMemberAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
if (!loaded) return null;
|
||||||
|
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return (
|
||||||
|
<div className="member-guard" style={{ position:'relative', minHeight:120 }}>
|
||||||
|
{children}
|
||||||
|
<div className="member-guard-overlay">
|
||||||
|
<div className="member-guard-icon">🔒</div>
|
||||||
|
<div className="member-guard-text">{feature}은 회원 전용입니다</div>
|
||||||
|
<div className="member-guard-sub">로그인 후 이용하실 수 있습니다</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/login')}
|
||||||
|
style={{ marginTop:8, padding:'8px 24px', background:'#1a5fd8', color:'#fff',
|
||||||
|
border:'none', borderRadius:8, cursor:'pointer', fontWeight:600, fontSize:14 }}>
|
||||||
|
로그인 / 회원가입
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@ -1,6 +1,8 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import './Contact.css';
|
import './Contact.css';
|
||||||
|
import './MemberAuth.css';
|
||||||
|
import { MemberOnly } from '../hooks/useMemberAuth.jsx';
|
||||||
|
|
||||||
export default function Contact() {
|
export default function Contact() {
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
@ -30,6 +32,7 @@ export default function Contact() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<MemberOnly feature="문의 상담 신청">
|
||||||
<main id="main-content" className="contact-page">
|
<main id="main-content" className="contact-page">
|
||||||
<div className="page-hero">
|
<div className="page-hero">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
@ -122,5 +125,6 @@ export default function Contact() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
</MemberOnly>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
139
frontend/src/pages/MemberAuth.css
Normal file
139
frontend/src/pages/MemberAuth.css
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
/* ── 회원 인증 페이지 공통 스타일 ─────────────────────────────── */
|
||||||
|
.auth-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #f0f4ff 0%, #e8f0fe 100%);
|
||||||
|
padding: 40px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-box {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 4px 32px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 40px 40px 32px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 440px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--primary, #1a3a6b);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 탭 */
|
||||||
|
.auth-tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 2px solid #e2e8f0;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.auth-tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
transition: all .2s;
|
||||||
|
}
|
||||||
|
.auth-tab.active {
|
||||||
|
color: var(--accent, #1a5fd8);
|
||||||
|
border-bottom-color: var(--accent, #1a5fd8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #dc2626;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 폼 */
|
||||||
|
.auth-form { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
.auth-form .form-group { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.auth-form label { font-size: 13px; font-weight: 600; color: #374151; }
|
||||||
|
.auth-form .required { color: #ef4444; }
|
||||||
|
.auth-form input {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1.5px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1e293b;
|
||||||
|
transition: border-color .2s;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.auth-form input:focus { border-color: var(--accent, #1a5fd8); }
|
||||||
|
|
||||||
|
.btn-full { width: 100%; justify-content: center; }
|
||||||
|
|
||||||
|
/* SNS */
|
||||||
|
.sns-divider {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
font-size: 12px; color: #94a3b8; margin: 4px 0;
|
||||||
|
}
|
||||||
|
.sns-divider::before, .sns-divider::after {
|
||||||
|
content: ''; flex: 1; height: 1px; background: #e2e8f0;
|
||||||
|
}
|
||||||
|
.sns-buttons { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.sns-btn {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 10px 16px; border-radius: 8px; border: 1.5px solid #e2e8f0;
|
||||||
|
font-size: 14px; font-weight: 600; cursor: pointer;
|
||||||
|
background: #fff; transition: all .2s;
|
||||||
|
}
|
||||||
|
.sns-btn:hover { transform: translateY(-1px); box-shadow: 0 2px 8px rgba(0,0,0,.08); }
|
||||||
|
.sns-kakao { border-color: #FEE500; background: #FEE500; color: #191919; }
|
||||||
|
.sns-naver { border-color: #03C75A; background: #03C75A; color: #fff; }
|
||||||
|
.sns-google { border-color: #e2e8f0; color: #374151; }
|
||||||
|
.sns-icon { width: 20px; text-align: center; font-weight: 900; }
|
||||||
|
|
||||||
|
.auth-switch {
|
||||||
|
font-size: 13px; color: #64748b; text-align: center; margin: 0;
|
||||||
|
}
|
||||||
|
.link-btn {
|
||||||
|
background: none; border: none; color: var(--accent, #1a5fd8);
|
||||||
|
font-weight: 600; cursor: pointer; text-decoration: underline;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
.auth-terms {
|
||||||
|
font-size: 11px; color: #94a3b8; text-align: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.auth-terms a { color: #64748b; }
|
||||||
|
|
||||||
|
/* 회원 전용 잠금 오버레이 */
|
||||||
|
.member-guard {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.member-guard-overlay {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background: rgba(255,255,255,.85);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
border-radius: inherit;
|
||||||
|
z-index: 10;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.member-guard-icon { font-size: 36px; }
|
||||||
|
.member-guard-text { font-size: 15px; font-weight: 600; color: #1e293b; }
|
||||||
|
.member-guard-sub { font-size: 13px; color: #64748b; }
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.auth-box { padding: 28px 20px 24px; }
|
||||||
|
}
|
||||||
239
frontend/src/pages/MemberLogin.jsx
Normal file
239
frontend/src/pages/MemberLogin.jsx
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import './Common.css';
|
||||||
|
import './MemberAuth.css';
|
||||||
|
|
||||||
|
const API = '/api/members';
|
||||||
|
|
||||||
|
export default function MemberLogin() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [tab, setTab] = useState('login'); // login | register
|
||||||
|
const [form, setForm] = useState({ name:'', email:'', password:'', phone:'', company:'' });
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading,setLoading] = useState(false);
|
||||||
|
|
||||||
|
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
|
const handleLogin = async e => {
|
||||||
|
e.preventDefault(); setError(''); setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/login`, {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ email: form.email, password: form.password }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) { setError(data.message || '로그인 실패'); return; }
|
||||||
|
localStorage.setItem('member_token', data.token);
|
||||||
|
localStorage.setItem('member_user', JSON.stringify(data));
|
||||||
|
navigate('/');
|
||||||
|
} catch { setError('서버 연결 오류'); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegister = async e => {
|
||||||
|
e.preventDefault(); setError(''); setLoading(true);
|
||||||
|
if (form.password.length < 8) { setError('비밀번호는 8자 이상이어야 합니다.'); setLoading(false); return; }
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/register`, {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify(form),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) { setError(data.message || '가입 실패'); return; }
|
||||||
|
localStorage.setItem('member_token', data.token);
|
||||||
|
localStorage.setItem('member_user', JSON.stringify(data));
|
||||||
|
navigate('/');
|
||||||
|
} catch { setError('서버 연결 오류'); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// SNS 로그인 핸들러 (SDK 연동 후 콜백)
|
||||||
|
const handleSnsLogin = async (provider, profile) => {
|
||||||
|
setError(''); setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/social-login`, {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ provider, email: profile.email, name: profile.name, id: profile.id }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) { setError(data.message || 'SNS 로그인 실패'); return; }
|
||||||
|
localStorage.setItem('member_token', data.token);
|
||||||
|
localStorage.setItem('member_user', JSON.stringify(data));
|
||||||
|
navigate('/');
|
||||||
|
} catch { setError('SNS 로그인 오류'); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// 카카오 로그인 (kakao SDK 필요: https://developers.kakao.com)
|
||||||
|
const loginKakao = () => {
|
||||||
|
if (!window.Kakao?.Auth) { alert('카카오 SDK를 불러오는 중입니다. 잠시 후 시도해 주세요.'); return; }
|
||||||
|
window.Kakao.Auth.login({
|
||||||
|
success: authObj => {
|
||||||
|
window.Kakao.API.request({
|
||||||
|
url: '/v2/user/me',
|
||||||
|
success: res => {
|
||||||
|
const profile = {
|
||||||
|
id: String(res.id),
|
||||||
|
name: res.kakao_account?.profile?.nickname || '카카오회원',
|
||||||
|
email: res.kakao_account?.email || `kakao_${res.id}@kakao.user`,
|
||||||
|
};
|
||||||
|
handleSnsLogin('kakao', profile);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
fail: () => setError('카카오 로그인에 실패했습니다.'),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 네이버 로그인 (naver SDK 필요: https://developers.naver.com)
|
||||||
|
const loginNaver = () => {
|
||||||
|
const naverLogin = window.naver_id_login;
|
||||||
|
if (!naverLogin) { alert('네이버 SDK를 불러오는 중입니다.'); return; }
|
||||||
|
naverLogin.getLoginStatus(status => {
|
||||||
|
if (status) {
|
||||||
|
const profile = {
|
||||||
|
id: naverLogin.getProfileData('id'),
|
||||||
|
name: naverLogin.getProfileData('name'),
|
||||||
|
email: naverLogin.getProfileData('email'),
|
||||||
|
};
|
||||||
|
handleSnsLogin('naver', profile);
|
||||||
|
} else {
|
||||||
|
naverLogin.authorize();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 구글 로그인 (Google Identity Services 필요)
|
||||||
|
const loginGoogle = () => {
|
||||||
|
if (!window.google?.accounts) { alert('구글 SDK를 불러오는 중입니다.'); return; }
|
||||||
|
window.google.accounts.id.prompt(notification => {
|
||||||
|
if (notification.isNotDisplayed()) setError('구글 로그인 팝업이 차단됐습니다.');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="auth-page">
|
||||||
|
<div className="auth-box">
|
||||||
|
{/* 로고 */}
|
||||||
|
<Link to="/" className="auth-logo">
|
||||||
|
<img src="/logo.png" alt="지오정보기술" height="36"
|
||||||
|
onError={e => { e.target.style.display='none'; }} />
|
||||||
|
<span>(주)지오정보기술</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* 탭 */}
|
||||||
|
<div className="auth-tabs">
|
||||||
|
<button className={`auth-tab ${tab==='login' ? 'active' : ''}`} onClick={() => { setTab('login'); setError(''); }}>
|
||||||
|
로그인
|
||||||
|
</button>
|
||||||
|
<button className={`auth-tab ${tab==='register' ? 'active' : ''}`} onClick={() => { setTab('register'); setError(''); }}>
|
||||||
|
회원가입
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="auth-error">{error}</div>}
|
||||||
|
|
||||||
|
{/* ── 로그인 폼 ── */}
|
||||||
|
{tab === 'login' && (
|
||||||
|
<form onSubmit={handleLogin} className="auth-form">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>이메일</label>
|
||||||
|
<input type="email" value={form.email} onChange={e => set('email', e.target.value)}
|
||||||
|
placeholder="이메일 주소" required />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>비밀번호</label>
|
||||||
|
<input type="password" value={form.password} onChange={e => set('password', e.target.value)}
|
||||||
|
placeholder="비밀번호" required />
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn-primary btn-full" disabled={loading}>
|
||||||
|
{loading ? '로그인 중...' : '로그인'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* SNS 로그인 */}
|
||||||
|
<div className="sns-divider"><span>또는 SNS 로그인</span></div>
|
||||||
|
<div className="sns-buttons">
|
||||||
|
<button type="button" className="sns-btn sns-kakao" onClick={loginKakao}>
|
||||||
|
<span className="sns-icon">💬</span> 카카오로 로그인
|
||||||
|
</button>
|
||||||
|
<button type="button" className="sns-btn sns-naver" onClick={loginNaver}>
|
||||||
|
<span className="sns-icon">N</span> 네이버로 로그인
|
||||||
|
</button>
|
||||||
|
<button type="button" className="sns-btn sns-google" onClick={loginGoogle}>
|
||||||
|
<span className="sns-icon">G</span> 구글로 로그인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="auth-switch">
|
||||||
|
계정이 없으신가요?{' '}
|
||||||
|
<button type="button" className="link-btn" onClick={() => setTab('register')}>
|
||||||
|
회원가입
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 회원가입 폼 ── */}
|
||||||
|
{tab === 'register' && (
|
||||||
|
<form onSubmit={handleRegister} className="auth-form">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>이름 <span className="required">*</span></label>
|
||||||
|
<input type="text" value={form.name} onChange={e => set('name', e.target.value)}
|
||||||
|
placeholder="실명을 입력해 주세요" required />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>이메일 <span className="required">*</span></label>
|
||||||
|
<input type="email" value={form.email} onChange={e => set('email', e.target.value)}
|
||||||
|
placeholder="이메일 주소" required />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>비밀번호 <span className="required">*</span></label>
|
||||||
|
<input type="password" value={form.password} onChange={e => set('password', e.target.value)}
|
||||||
|
placeholder="8자 이상" required minLength={8} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>연락처</label>
|
||||||
|
<input type="tel" value={form.phone} onChange={e => set('phone', e.target.value)}
|
||||||
|
placeholder="010-0000-0000" />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>소속 기관/회사</label>
|
||||||
|
<input type="text" value={form.company} onChange={e => set('company', e.target.value)}
|
||||||
|
placeholder="소속 기관이나 회사명 (선택)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="btn btn-primary btn-full" disabled={loading}>
|
||||||
|
{loading ? '가입 중...' : '회원가입'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* SNS 회원가입 */}
|
||||||
|
<div className="sns-divider"><span>또는 SNS로 간편 가입</span></div>
|
||||||
|
<div className="sns-buttons">
|
||||||
|
<button type="button" className="sns-btn sns-kakao" onClick={loginKakao}>
|
||||||
|
<span className="sns-icon">💬</span> 카카오로 시작
|
||||||
|
</button>
|
||||||
|
<button type="button" className="sns-btn sns-naver" onClick={loginNaver}>
|
||||||
|
<span className="sns-icon">N</span> 네이버로 시작
|
||||||
|
</button>
|
||||||
|
<button type="button" className="sns-btn sns-google" onClick={loginGoogle}>
|
||||||
|
<span className="sns-icon">G</span> 구글로 시작
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="auth-switch">
|
||||||
|
이미 계정이 있으신가요?{' '}
|
||||||
|
<button type="button" className="link-btn" onClick={() => setTab('login')}>
|
||||||
|
로그인
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="auth-terms">
|
||||||
|
회원가입 시 <Link to="/terms">이용약관</Link> 및{' '}
|
||||||
|
<Link to="/privacy">개인정보처리방침</Link>에 동의하는 것으로 간주됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ const NAV = [
|
|||||||
{ path: '/admin/recruit', icon: '👥', label: '채용공고' },
|
{ path: '/admin/recruit', icon: '👥', label: '채용공고' },
|
||||||
{ section: '고객 관리' },
|
{ section: '고객 관리' },
|
||||||
{ path: '/admin/inquiries', icon: '📩', label: '문의 관리', badgeKey: 'pendingInquiries' },
|
{ path: '/admin/inquiries', icon: '📩', label: '문의 관리', badgeKey: 'pendingInquiries' },
|
||||||
|
{ path: '/admin/members', icon: '👤', label: '회원 관리' },
|
||||||
{ section: '시스템' },
|
{ section: '시스템' },
|
||||||
{ path: '/admin/settings', icon: '⚙️', label: '설정' },
|
{ path: '/admin/settings', icon: '⚙️', label: '설정' },
|
||||||
];
|
];
|
||||||
@ -34,7 +35,8 @@ export default function AdminLayout() {
|
|||||||
'/admin/dashboard': '대시보드',
|
'/admin/dashboard': '대시보드',
|
||||||
'/admin/news': '뉴스/공지사항 관리',
|
'/admin/news': '뉴스/공지사항 관리',
|
||||||
'/admin/inquiries': '문의 관리',
|
'/admin/inquiries': '문의 관리',
|
||||||
'/admin/recruit': '채용공고 관리',
|
'/admin/recruit': '채용공고 관리',
|
||||||
|
'/admin/members': '회원 관리',
|
||||||
'/admin/settings': '설정',
|
'/admin/settings': '설정',
|
||||||
};
|
};
|
||||||
setPageTitle(map[location.pathname] || '관리자');
|
setPageTitle(map[location.pathname] || '관리자');
|
||||||
|
|||||||
162
frontend/src/pages/admin/AdminMember.jsx
Normal file
162
frontend/src/pages/admin/AdminMember.jsx
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const API = '/api/admin/members';
|
||||||
|
const token = () => localStorage.getItem('admin_token');
|
||||||
|
const headers = () => ({ Authorization: `Bearer ${token()}`, 'Content-Type': 'application/json' });
|
||||||
|
|
||||||
|
export default function AdminMember() {
|
||||||
|
const [data, setData] = useState({ content: [], totalElements: 0 });
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const load = async (p = page, kw = search) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const qs = new URLSearchParams({ page: p, size: 20, ...(kw && { keyword: kw }) });
|
||||||
|
const res = await fetch(`${API}?${qs}`, { headers: headers() });
|
||||||
|
if (res.ok) setData(await res.json());
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [page, search]);
|
||||||
|
|
||||||
|
const handleSearch = e => { e.preventDefault(); setPage(0); setSearch(keyword); };
|
||||||
|
|
||||||
|
const toggleStatus = async (id, active) => {
|
||||||
|
if (!confirm(`${active ? '비활성화' : '활성화'}하시겠습니까?`)) return;
|
||||||
|
await fetch(`${API}/${id}/status`, {
|
||||||
|
method: 'PATCH', headers: headers(),
|
||||||
|
body: JSON.stringify({ active: !active }),
|
||||||
|
});
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteMember = async id => {
|
||||||
|
if (!confirm('삭제하면 복구할 수 없습니다. 삭제하시겠습니까?')) return;
|
||||||
|
await fetch(`${API}/${id}`, { method: 'DELETE', headers: headers() });
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const members = data.content || [];
|
||||||
|
const total = data.totalElements || 0;
|
||||||
|
const totalPages = Math.ceil(total / 20);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:24 }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize:20, fontWeight:700, color:'#1e293b', margin:0 }}>회원 관리</h2>
|
||||||
|
<p style={{ fontSize:13, color:'#64748b', margin:'4px 0 0' }}>총 {total.toLocaleString()}명</p>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSearch} style={{ display:'flex', gap:8 }}>
|
||||||
|
<input
|
||||||
|
value={keyword}
|
||||||
|
onChange={e => setKeyword(e.target.value)}
|
||||||
|
placeholder="이름 / 이메일 / 소속 검색"
|
||||||
|
style={{ padding:'8px 14px', border:'1px solid #e2e8f0', borderRadius:8,
|
||||||
|
fontSize:13, width:240, outline:'none' }}
|
||||||
|
/>
|
||||||
|
<button type="submit"
|
||||||
|
style={{ padding:'8px 16px', background:'#1a5fd8', color:'#fff',
|
||||||
|
border:'none', borderRadius:8, fontSize:13, cursor:'pointer' }}>
|
||||||
|
검색
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div style={{ background:'#fff', border:'1px solid #e2e8f0', borderRadius:10, overflow:'hidden' }}>
|
||||||
|
<table style={{ width:'100%', borderCollapse:'collapse', fontSize:13 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background:'#f8fafc' }}>
|
||||||
|
{['ID','이름','이메일','연락처','소속','가입일','상태','액션'].map(h => (
|
||||||
|
<th key={h} style={{ padding:'10px 14px', textAlign:'left', fontWeight:600,
|
||||||
|
color:'#475569', borderBottom:'1px solid #e2e8f0' }}>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan={8} style={{ padding:40, textAlign:'center', color:'#94a3b8' }}>
|
||||||
|
로딩 중...
|
||||||
|
</td></tr>
|
||||||
|
) : members.length === 0 ? (
|
||||||
|
<tr><td colSpan={8} style={{ padding:40, textAlign:'center', color:'#94a3b8' }}>
|
||||||
|
{search ? '검색 결과가 없습니다.' : '등록된 회원이 없습니다.'}
|
||||||
|
</td></tr>
|
||||||
|
) : members.map(m => (
|
||||||
|
<tr key={m.id} style={{ borderBottom:'1px solid #f1f5f9' }}>
|
||||||
|
<td style={{ padding:'10px 14px', color:'#94a3b8' }}>#{m.id}</td>
|
||||||
|
<td style={{ padding:'10px 14px', fontWeight:600, color:'#1e293b' }}>{m.name}</td>
|
||||||
|
<td style={{ padding:'10px 14px', color:'#475569' }}>{m.email}</td>
|
||||||
|
<td style={{ padding:'10px 14px', color:'#64748b' }}>{m.phone || '-'}</td>
|
||||||
|
<td style={{ padding:'10px 14px', color:'#64748b' }}>{m.company || '-'}</td>
|
||||||
|
<td style={{ padding:'10px 14px', color:'#64748b', fontSize:12 }}>
|
||||||
|
{m.createdAt ? new Date(m.createdAt).toLocaleDateString('ko-KR') : '-'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding:'10px 14px' }}>
|
||||||
|
<span style={{
|
||||||
|
padding:'3px 10px', borderRadius:12, fontSize:11, fontWeight:700,
|
||||||
|
background: m.active ? '#dcfce7' : '#fee2e2',
|
||||||
|
color: m.active ? '#16a34a' : '#dc2626',
|
||||||
|
}}>
|
||||||
|
{m.active ? '활성' : '비활성'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding:'10px 14px' }}>
|
||||||
|
<div style={{ display:'flex', gap:6 }}>
|
||||||
|
<button onClick={() => toggleStatus(m.id, m.active)}
|
||||||
|
style={{ padding:'4px 10px', fontSize:11, borderRadius:6, cursor:'pointer',
|
||||||
|
background: m.active ? '#fef3c7' : '#dcfce7',
|
||||||
|
color: m.active ? '#92400e' : '#166534',
|
||||||
|
border:'none' }}>
|
||||||
|
{m.active ? '비활성화' : '활성화'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => deleteMember(m.id)}
|
||||||
|
style={{ padding:'4px 10px', fontSize:11, borderRadius:6, cursor:'pointer',
|
||||||
|
background:'#fef2f2', color:'#dc2626', border:'none' }}>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div style={{ display:'flex', gap:6, justifyContent:'center', marginTop:20 }}>
|
||||||
|
<button onClick={() => setPage(p => Math.max(0, p-1))} disabled={page===0}
|
||||||
|
style={{ padding:'6px 14px', borderRadius:6, border:'1px solid #e2e8f0',
|
||||||
|
background:'#fff', cursor:'pointer', fontSize:13 }}>
|
||||||
|
이전
|
||||||
|
</button>
|
||||||
|
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||||
|
const p = Math.max(0, Math.min(page - 2, totalPages - 5)) + i;
|
||||||
|
return (
|
||||||
|
<button key={p} onClick={() => setPage(p)}
|
||||||
|
style={{ padding:'6px 12px', borderRadius:6, fontSize:13, cursor:'pointer',
|
||||||
|
border: p===page ? 'none' : '1px solid #e2e8f0',
|
||||||
|
background: p===page ? '#1a5fd8' : '#fff',
|
||||||
|
color: p===page ? '#fff' : '#475569' }}>
|
||||||
|
{p+1}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button onClick={() => setPage(p => Math.min(totalPages-1, p+1))} disabled={page===totalPages-1}
|
||||||
|
style={{ padding:'6px 14px', borderRadius:6, border:'1px solid #e2e8f0',
|
||||||
|
background:'#fff', cursor:'pointer', fontSize:13 }}>
|
||||||
|
다음
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user