diff --git a/src/App.tsx b/src/App.tsx index 50e3bd9..8935169 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { ProjectsPage } from "@/pages/Projects"; import { ProjectDetailPage } from "@/pages/ProjectDetail"; import { IncentivePage } from "@/pages/Incentive"; import { ProfilePage } from "@/pages/Profile"; +import { AccountSettingsPage } from "@/pages/AccountSettings"; import { InboxPage } from "@/pages/Inbox"; import { ApprovalsPage } from "@/pages/admin/Approvals"; import { AttendanceAdminPage } from "@/pages/admin/AttendanceAdmin"; @@ -41,6 +42,7 @@ function Shell() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/AccountMenu.tsx b/src/components/AccountMenu.tsx new file mode 100644 index 0000000..245d155 --- /dev/null +++ b/src/components/AccountMenu.tsx @@ -0,0 +1,78 @@ +import { useEffect, useRef, useState } from "react"; +import { Link } from "react-router-dom"; +import { ChevronDown, User as UserIcon, UserCircle, Settings, LogOut } from "lucide-react"; +import { useAuth } from "@/context/Auth"; +import { avatarUrl, logout } from "@/lib/api"; + +// 우측 상단 계정 메뉴: 클릭하면 프로필·계정 설정·로그아웃이 펼쳐집니다. +export function AccountMenu() { + const { me, isAdmin } = useAuth(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + const name = me?.member?.displayName || me?.user.name || "사용자"; + const rank = me?.member?.rank; + const email = me?.user.email; + const avatar = avatarUrl(me?.member?.id, me?.member?.avatarKey); + + useEffect(() => { + if (!open) return; + const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, [open]); + + const Avatar = ({ size }: { size: number }) => + avatar ? ( + {name} + ) : ( +
+ {name.slice(0, 1) || } +
+ ); + + return ( +
+ + + {open && ( +
+
+ +
+
{name}
+
{email}
+
+ {isAdmin ? "관리자" : "구성원"}{rank ? ` · ${rank}` : ""} +
+
+
+ } label="내 프로필" onClick={() => setOpen(false)} /> + } label="계정 설정" onClick={() => setOpen(false)} /> +
+ +
+ )} +
+ ); +} + +function MenuLink({ to, icon, label, onClick }: { to: string; icon: React.ReactNode; label: string; onClick: () => void }) { + return ( + + {icon}{label} + + ); +} diff --git a/src/components/Topbar.tsx b/src/components/Topbar.tsx index 263b52c..249d849 100644 --- a/src/components/Topbar.tsx +++ b/src/components/Topbar.tsx @@ -1,8 +1,9 @@ import { Link } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; -import { ShieldCheck, User as UserIcon, PanelLeftClose, PanelLeftOpen, Bell } from "lucide-react"; +import { ShieldCheck, PanelLeftClose, PanelLeftOpen, Bell } from "lucide-react"; import { useAuth } from "@/context/Auth"; -import { getUnreadCount, avatarUrl } from "@/lib/api"; +import { getUnreadCount } from "@/lib/api"; +import { AccountMenu } from "./AccountMenu"; export function Topbar({ collapsed, @@ -11,12 +12,7 @@ export function Topbar({ collapsed?: boolean; onToggleSidebar?: () => void; }) { - const { me, isAdmin } = useAuth(); - const name = me?.member?.displayName || me?.user.name || "사용자"; - const rank = me?.member?.rank; - const initial = name.slice(0, 1); - const avatar = avatarUrl(me?.member?.id, me?.member?.avatarKey); - + const { isAdmin } = useAuth(); const unreadQ = useQuery({ queryKey: ["unread-count"], queryFn: getUnreadCount, refetchInterval: 60_000 }); const unread = unreadQ.data ?? 0; @@ -49,19 +45,7 @@ export function Topbar({ )} - - {avatar ? ( - {name} - ) : ( -
- {initial || } -
- )} -
-
{name}{rank ? ` · ${rank}` : ""}
-
{me?.user.email}
-
- +
); diff --git a/src/lib/api.ts b/src/lib/api.ts index e11065d..c9cd642 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -17,6 +17,12 @@ export const api = axios.create({ const asParam = new URLSearchParams(window.location.search).get("as"); if (asParam === "user") api.defaults.params = { as: "user" }; +/* ---- auth / session ---- */ +// Logout: oauth2-proxy(Keycloak) 세션을 종료. (프론트 앞단 VirtualService가 /oauth2/* 를 +// oauth2-proxy로 라우팅) — 로컬 DEV_AUTH mock 환경에선 동작하지 않습니다. +export const SIGN_OUT_URL = "/oauth2/sign_out?rd=%2F"; +export const logout = () => { window.location.href = SIGN_OUT_URL; }; + /* ---- identity / nav ---- */ export const getMe = () => api.get("/me").then((r) => r.data); export const getNav = () => api.get("/me/nav").then((r) => r.data); diff --git a/src/pages/AccountSettings.tsx b/src/pages/AccountSettings.tsx new file mode 100644 index 0000000..297cb51 --- /dev/null +++ b/src/pages/AccountSettings.tsx @@ -0,0 +1,94 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { LogOut, ShieldCheck } from "lucide-react"; +import { updateMember, logout } from "@/lib/api"; +import { useAuth } from "@/context/Auth"; +import { Card, CardHeader, Button, Field, Input, PageHeader, LoadingState } from "@/components/ui"; +import { classNames } from "@/lib/format"; + +// 계정 설정: 표시 이름·알림·화면 환경설정 + 로그아웃. 비밀번호/2단계 인증 등 계정 보안은 +// Keycloak이 담당하며, 계정 생성/삭제도 Keycloak에서 이뤄집니다. +export function AccountSettingsPage() { + const { me, loading } = useAuth(); + const qc = useQueryClient(); + const member = me?.member; + const [displayName, setDisplayName] = useState(member?.displayName ?? ""); + + const save = useMutation({ + mutationFn: () => updateMember(member!.id, { displayName }), + onSuccess: () => qc.invalidateQueries({ queryKey: ["me"] }), + }); + + // 알림/화면 환경설정 (클라이언트 보관) + const [prefs, setPrefs] = useState(() => ({ + notifyProject: localStorage.getItem("spin.notify.project") !== "0", + notifyWork: localStorage.getItem("spin.notify.work") !== "0", + notifyIncentive: localStorage.getItem("spin.notify.incentive") !== "0", + sidebarCollapsed: localStorage.getItem("spin.sidebarCollapsed") === "1", + })); + const setPref = (k: keyof typeof prefs, key: string, v: boolean) => { + setPrefs((p) => ({ ...p, [k]: v })); + localStorage.setItem(key, v ? "1" : "0"); + }; + + if (loading) return ; + if (!member) return 구성원 정보가 없습니다. 관리자에게 문의하세요.; + + return ( +
+ + + + save.mutate()} disabled={save.isPending}>저장} /> +
+ setDisplayName(e.target.value)} /> + +
+
+ + + +
+ setPref("notifyProject", "spin.notify.project", v)} /> + setPref("notifyWork", "spin.notify.work", v)} /> + setPref("notifyIncentive", "spin.notify.incentive", v)} /> +
+
+ + + +
+ setPref("sidebarCollapsed", "spin.sidebarCollapsed", v)} /> +
+
+ + + +
+
+ +

비밀번호 변경·2단계 인증 등 계정 보안과 계정 생성/삭제는 Keycloak 계정에서 관리됩니다.

+
+ +
+
+
+ ); +} + +function ToggleRow({ label, desc, on, onChange }: { label: string; desc?: string; on: boolean; onChange: (v: boolean) => void }) { + return ( +
+
+
{label}
+ {desc &&
{desc}
} +
+ +
+ ); +}