From 524235e8fe7350e3e8f6c6837a242ce6283161a7 Mon Sep 17 00:00:00 2001 From: "DESKTOP-TKLFCPR\\ython" Date: Sun, 31 May 2026 11:49:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(homepage):=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=C2=B7=EB=A1=9C=EA=B7=B8=EC=9D=B8=C2=B7SNS=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20+=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 홈페이지 (프론트엔드) - 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 --- .../web/controller/AdminController.java | 40 +++ .../web/controller/MemberController.java | 149 +++++++++++ .../java/kr/co/zioinfo/web/model/Member.java | 41 +++ .../web/repository/MemberRepository.java | 22 ++ workspace/zioinfo-web/frontend/src/App.jsx | 7 + .../frontend/src/components/layout/Header.jsx | 57 ++++- .../frontend/src/hooks/useMemberAuth.jsx | 83 ++++++ .../frontend/src/pages/Contact.jsx | 4 + .../frontend/src/pages/MemberAuth.css | 139 ++++++++++ .../frontend/src/pages/MemberLogin.jsx | 239 ++++++++++++++++++ .../frontend/src/pages/admin/AdminLayout.jsx | 4 +- .../frontend/src/pages/admin/AdminMember.jsx | 162 ++++++++++++ 12 files changed, 937 insertions(+), 10 deletions(-) create mode 100644 workspace/zioinfo-web/backend/src/main/java/kr/co/zioinfo/web/controller/MemberController.java create mode 100644 workspace/zioinfo-web/backend/src/main/java/kr/co/zioinfo/web/model/Member.java create mode 100644 workspace/zioinfo-web/backend/src/main/java/kr/co/zioinfo/web/repository/MemberRepository.java create mode 100644 workspace/zioinfo-web/frontend/src/hooks/useMemberAuth.jsx create mode 100644 workspace/zioinfo-web/frontend/src/pages/MemberAuth.css create mode 100644 workspace/zioinfo-web/frontend/src/pages/MemberLogin.jsx create mode 100644 workspace/zioinfo-web/frontend/src/pages/admin/AdminMember.jsx diff --git a/workspace/zioinfo-web/backend/src/main/java/kr/co/zioinfo/web/controller/AdminController.java b/workspace/zioinfo-web/backend/src/main/java/kr/co/zioinfo/web/controller/AdminController.java index bb1cb0a2..d5b0c926 100644 --- a/workspace/zioinfo-web/backend/src/main/java/kr/co/zioinfo/web/controller/AdminController.java +++ b/workspace/zioinfo-web/backend/src/main/java/kr/co/zioinfo/web/controller/AdminController.java @@ -19,6 +19,7 @@ public class AdminController { private final NewsRepository newsRepo; private final InquiryRepository inquiryRepo; private final RecruitRepository recruitRepo; + private final MemberRepository memberRepo; private final JwtUtil jwtUtil; private final PasswordEncoder passwordEncoder; @@ -47,6 +48,8 @@ public class AdminController { stats.put("pendingInquiries", inquiryRepo.countByStatus("PENDING")); stats.put("totalRecruits", recruitRepo.count()); stats.put("activeRecruits", recruitRepo.countByActiveTrue()); + stats.put("totalMembers", memberRepo.count()); + stats.put("activeMembers", memberRepo.countByActiveTrue()); stats.put("recentInquiries", inquiryRepo.findTop5ByOrderByCreatedAtDesc()); stats.put("recentNews", newsRepo.findTop5ByOrderByCreatedAtDesc()); return ResponseEntity.ok(stats); @@ -164,6 +167,43 @@ public class AdminController { 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 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") public ResponseEntity changePassword( diff --git a/workspace/zioinfo-web/backend/src/main/java/kr/co/zioinfo/web/controller/MemberController.java b/workspace/zioinfo-web/backend/src/main/java/kr/co/zioinfo/web/controller/MemberController.java new file mode 100644 index 00000000..f2f3c441 --- /dev/null +++ b/workspace/zioinfo-web/backend/src/main/java/kr/co/zioinfo/web/controller/MemberController.java @@ -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 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 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 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 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 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 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 buildResponse(Member m, String token) { + Map 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; + } +} diff --git a/workspace/zioinfo-web/backend/src/main/java/kr/co/zioinfo/web/model/Member.java b/workspace/zioinfo-web/backend/src/main/java/kr/co/zioinfo/web/model/Member.java new file mode 100644 index 00000000..4b4f14cf --- /dev/null +++ b/workspace/zioinfo-web/backend/src/main/java/kr/co/zioinfo/web/model/Member.java @@ -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; +} diff --git a/workspace/zioinfo-web/backend/src/main/java/kr/co/zioinfo/web/repository/MemberRepository.java b/workspace/zioinfo-web/backend/src/main/java/kr/co/zioinfo/web/repository/MemberRepository.java new file mode 100644 index 00000000..9b6bb8aa --- /dev/null +++ b/workspace/zioinfo-web/backend/src/main/java/kr/co/zioinfo/web/repository/MemberRepository.java @@ -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 { + + Optional 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 search(@Param("keyword") String keyword, Pageable pageable); + + long countByActiveTrue(); +} diff --git a/workspace/zioinfo-web/frontend/src/App.jsx b/workspace/zioinfo-web/frontend/src/App.jsx index 04abb3de..a005bdfd 100644 --- a/workspace/zioinfo-web/frontend/src/App.jsx +++ b/workspace/zioinfo-web/frontend/src/App.jsx @@ -14,6 +14,9 @@ const NewsPage = lazy(() => import('./pages/NewsPage')); const Recruit = lazy(() => import('./pages/Recruit')); const NotFound = lazy(() => import('./pages/NotFound')); +// Member Auth +const MemberLogin = lazy(() => import('./pages/MemberLogin')); + // Admin const AdminLogin = lazy(() => import('./pages/admin/AdminLogin')); 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 AdminRecruit = lazy(() => import('./pages/admin/AdminRecruit')); const AdminSettings = lazy(() => import('./pages/admin/AdminSettings')); +const AdminMember = lazy(() => import('./pages/admin/AdminMember')); function Loading() { return ( @@ -57,6 +61,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> @@ -77,6 +82,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> diff --git a/workspace/zioinfo-web/frontend/src/components/layout/Header.jsx b/workspace/zioinfo-web/frontend/src/components/layout/Header.jsx index 4a415f68..9be2ce16 100644 --- a/workspace/zioinfo-web/frontend/src/components/layout/Header.jsx +++ b/workspace/zioinfo-web/frontend/src/components/layout/Header.jsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { Link, useLocation } from 'react-router-dom'; +import React, { useState, useEffect } from 'react'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; import './Header.css'; const MENU = [ @@ -59,7 +59,27 @@ export default function Header() { const [scrolled, setScrolled] = useState(false); const [activeMenu, setActiveMenu] = useState(null); const [mobileOpen, setMobileOpen] = useState(false); + const [member, setMember] = useState(null); 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(() => { const onScroll = () => setScrolled(window.scrollY > 60); @@ -119,10 +139,25 @@ export default function Header() { ))} - {/* 문의 버튼 */} - - 문의하기 - + {/* 우측 버튼 영역 */} +
+ 문의하기 + {member ? ( +
+ + {member.name}님 + + +
+ ) : ( + 로그인 + )} +
{/* 햄버거 (모바일) */} + : 로그인 / 가입 + } + )} diff --git a/workspace/zioinfo-web/frontend/src/hooks/useMemberAuth.jsx b/workspace/zioinfo-web/frontend/src/hooks/useMemberAuth.jsx new file mode 100644 index 00000000..4b0222bf --- /dev/null +++ b/workspace/zioinfo-web/frontend/src/hooks/useMemberAuth.jsx @@ -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, + }; +} + +/** + * 회원 전용 접근 보호 컴포넌트. + * 비로그인 시 잠금 오버레이 표시. + * + * 사용: + * + * + * + */ +export function MemberOnly({ children, feature = '이 기능' }) { + const { isLoggedIn, loaded } = useMemberAuth(); + const navigate = useNavigate(); + + if (!loaded) return null; + + if (!isLoggedIn) { + return ( +
+ {children} +
+
🔒
+
{feature}은 회원 전용입니다
+
로그인 후 이용하실 수 있습니다
+ +
+
+ ); + } + + return children; +} diff --git a/workspace/zioinfo-web/frontend/src/pages/Contact.jsx b/workspace/zioinfo-web/frontend/src/pages/Contact.jsx index 4096328c..96c1d6fe 100644 --- a/workspace/zioinfo-web/frontend/src/pages/Contact.jsx +++ b/workspace/zioinfo-web/frontend/src/pages/Contact.jsx @@ -1,6 +1,8 @@ import React, { useState } from 'react'; import axios from 'axios'; import './Contact.css'; +import './MemberAuth.css'; +import { MemberOnly } from '../hooks/useMemberAuth.jsx'; export default function Contact() { const [form, setForm] = useState({ @@ -30,6 +32,7 @@ export default function Contact() { }; return ( +
@@ -122,5 +125,6 @@ export default function Contact() {
+
); } diff --git a/workspace/zioinfo-web/frontend/src/pages/MemberAuth.css b/workspace/zioinfo-web/frontend/src/pages/MemberAuth.css new file mode 100644 index 00000000..70820819 --- /dev/null +++ b/workspace/zioinfo-web/frontend/src/pages/MemberAuth.css @@ -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; } +} diff --git a/workspace/zioinfo-web/frontend/src/pages/MemberLogin.jsx b/workspace/zioinfo-web/frontend/src/pages/MemberLogin.jsx new file mode 100644 index 00000000..5f4965dc --- /dev/null +++ b/workspace/zioinfo-web/frontend/src/pages/MemberLogin.jsx @@ -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 ( +
+
+ {/* 로고 */} + + 지오정보기술 { e.target.style.display='none'; }} /> + (주)지오정보기술 + + + {/* 탭 */} +
+ + +
+ + {error &&
{error}
} + + {/* ── 로그인 폼 ── */} + {tab === 'login' && ( +
+
+ + set('email', e.target.value)} + placeholder="이메일 주소" required /> +
+
+ + set('password', e.target.value)} + placeholder="비밀번호" required /> +
+ + + {/* SNS 로그인 */} +
또는 SNS 로그인
+
+ + + +
+ +

+ 계정이 없으신가요?{' '} + +

+
+ )} + + {/* ── 회원가입 폼 ── */} + {tab === 'register' && ( +
+
+ + set('name', e.target.value)} + placeholder="실명을 입력해 주세요" required /> +
+
+ + set('email', e.target.value)} + placeholder="이메일 주소" required /> +
+
+ + set('password', e.target.value)} + placeholder="8자 이상" required minLength={8} /> +
+
+ + set('phone', e.target.value)} + placeholder="010-0000-0000" /> +
+
+ + set('company', e.target.value)} + placeholder="소속 기관이나 회사명 (선택)" /> +
+ + + + {/* SNS 회원가입 */} +
또는 SNS로 간편 가입
+
+ + + +
+ +

+ 이미 계정이 있으신가요?{' '} + +

+
+ )} + +

+ 회원가입 시 이용약관 및{' '} + 개인정보처리방침에 동의하는 것으로 간주됩니다. +

+
+
+ ); +} diff --git a/workspace/zioinfo-web/frontend/src/pages/admin/AdminLayout.jsx b/workspace/zioinfo-web/frontend/src/pages/admin/AdminLayout.jsx index a355799a..d9aa8b58 100644 --- a/workspace/zioinfo-web/frontend/src/pages/admin/AdminLayout.jsx +++ b/workspace/zioinfo-web/frontend/src/pages/admin/AdminLayout.jsx @@ -10,6 +10,7 @@ const NAV = [ { path: '/admin/recruit', icon: '👥', label: '채용공고' }, { section: '고객 관리' }, { path: '/admin/inquiries', icon: '📩', label: '문의 관리', badgeKey: 'pendingInquiries' }, + { path: '/admin/members', icon: '👤', label: '회원 관리' }, { section: '시스템' }, { path: '/admin/settings', icon: '⚙️', label: '설정' }, ]; @@ -34,7 +35,8 @@ export default function AdminLayout() { '/admin/dashboard': '대시보드', '/admin/news': '뉴스/공지사항 관리', '/admin/inquiries': '문의 관리', - '/admin/recruit': '채용공고 관리', + '/admin/recruit': '채용공고 관리', + '/admin/members': '회원 관리', '/admin/settings': '설정', }; setPageTitle(map[location.pathname] || '관리자'); diff --git a/workspace/zioinfo-web/frontend/src/pages/admin/AdminMember.jsx b/workspace/zioinfo-web/frontend/src/pages/admin/AdminMember.jsx new file mode 100644 index 00000000..86ac7704 --- /dev/null +++ b/workspace/zioinfo-web/frontend/src/pages/admin/AdminMember.jsx @@ -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 ( +
+ {/* 헤더 */} +
+
+

회원 관리

+

총 {total.toLocaleString()}명

+
+
+ setKeyword(e.target.value)} + placeholder="이름 / 이메일 / 소속 검색" + style={{ padding:'8px 14px', border:'1px solid #e2e8f0', borderRadius:8, + fontSize:13, width:240, outline:'none' }} + /> + +
+
+ + {/* 테이블 */} +
+ + + + {['ID','이름','이메일','연락처','소속','가입일','상태','액션'].map(h => ( + + ))} + + + + {loading ? ( + + ) : members.length === 0 ? ( + + ) : members.map(m => ( + + + + + + + + + + + ))} + +
+ {h} +
+ 로딩 중... +
+ {search ? '검색 결과가 없습니다.' : '등록된 회원이 없습니다.'} +
#{m.id}{m.name}{m.email}{m.phone || '-'}{m.company || '-'} + {m.createdAt ? new Date(m.createdAt).toLocaleDateString('ko-KR') : '-'} + + + {m.active ? '활성' : '비활성'} + + +
+ + +
+
+
+ + {/* 페이지네이션 */} + {totalPages > 1 && ( +
+ + {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { + const p = Math.max(0, Math.min(page - 2, totalPages - 5)) + i; + return ( + + ); + })} + +
+ )} +
+ ); +}