feat: 계정 메뉴(로그아웃) + 계정 설정 페이지
All checks were successful
build-and-push / build (push) Successful in 30s

- 우측 상단 계정 클릭 → 드롭다운(내 프로필·계정 설정·로그아웃)
- 로그아웃: oauth2-proxy(Keycloak) /oauth2/sign_out 으로 세션 종료
- 계정 설정(/account): 표시 이름 수정, 알림 환경설정, 사이드바 기본 접힘, 보안 안내(Keycloak)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
theorose49 2026-06-28 09:52:11 +09:00
parent 65bcb69374
commit 3a6d1b0440
5 changed files with 185 additions and 21 deletions

View File

@ -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() {
<Route path="/projects/:id" element={<ProjectDetailPage />} />
<Route path="/incentive" element={<IncentivePage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/account" element={<AccountSettingsPage />} />
<Route path="/inbox" element={<InboxPage />} />
<Route path="/admin/approvals" element={<RequireAdmin><ApprovalsPage /></RequireAdmin>} />
<Route path="/admin/attendance" element={<RequireAdmin><AttendanceAdminPage /></RequireAdmin>} />

View File

@ -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<HTMLDivElement>(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 ? (
<img src={avatar} alt={name} className="rounded-full object-cover" style={{ width: size, height: size }} />
) : (
<div className="rounded-full bg-navy text-white flex items-center justify-center font-bold"
style={{ width: size, height: size, fontSize: size * 0.45 }}>
{name.slice(0, 1) || <UserIcon size={size * 0.5} />}
</div>
);
return (
<div ref={ref} className="relative">
<button onClick={() => setOpen((o) => !o)} className="flex items-center gap-2.5 pl-1 pr-2 py-1 rounded-control hover:bg-canvas transition-colors">
<Avatar size={32} />
<div className="leading-tight text-left">
<div className="text-sm font-semibold text-ink">{name}{rank ? ` · ${rank}` : ""}</div>
<div className="text-[11px] text-ink-muted">{email}</div>
</div>
<ChevronDown size={15} className="text-ink-muted" />
</button>
{open && (
<div className="absolute right-0 mt-2 w-64 bg-surface rounded-card shadow-pop border border-border py-1.5 z-50">
<div className="flex items-center gap-3 px-4 py-3 border-b border-divider">
<Avatar size={40} />
<div className="min-w-0">
<div className="text-sm font-bold text-ink truncate">{name}</div>
<div className="text-[11px] text-ink-muted truncate">{email}</div>
<div className="mt-1 inline-flex items-center text-[10px] font-medium text-navy bg-navy-subtle rounded-pill px-1.5 py-0.5">
{isAdmin ? "관리자" : "구성원"}{rank ? ` · ${rank}` : ""}
</div>
</div>
</div>
<MenuLink to="/profile" icon={<UserCircle size={16} />} label="내 프로필" onClick={() => setOpen(false)} />
<MenuLink to="/account" icon={<Settings size={16} />} label="계정 설정" onClick={() => setOpen(false)} />
<div className="my-1 h-px bg-divider" />
<button
onClick={() => { setOpen(false); logout(); }}
className="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-[#B42318] hover:bg-[#FFFBFA] transition-colors"
>
<LogOut size={16} />
</button>
</div>
)}
</div>
);
}
function MenuLink({ to, icon, label, onClick }: { to: string; icon: React.ReactNode; label: string; onClick: () => void }) {
return (
<Link to={to} onClick={onClick} className="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-ink hover:bg-canvas transition-colors">
<span className="text-ink-secondary">{icon}</span>{label}
</Link>
);
}

View File

@ -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({
</span>
)}
</Link>
<Link to="/profile" className="flex items-center gap-2.5 hover:opacity-90" title="내 프로필">
{avatar ? (
<img src={avatar} alt={name} className="w-8 h-8 rounded-full object-cover" />
) : (
<div className="w-8 h-8 rounded-full bg-navy text-white flex items-center justify-center text-sm font-bold">
{initial || <UserIcon size={15} />}
</div>
)}
<div className="leading-tight">
<div className="text-sm font-semibold text-ink">{name}{rank ? ` · ${rank}` : ""}</div>
<div className="text-[11px] text-ink-muted">{me?.user.email}</div>
</div>
</Link>
<AccountMenu />
</div>
</header>
);

View File

@ -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>("/me").then((r) => r.data);
export const getNav = () => api.get<NavItem[]>("/me/nav").then((r) => r.data);

View File

@ -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 <LoadingState />;
if (!member) return <Card className="p-8 text-center text-ink-secondary"> . .</Card>;
return (
<div className="max-w-2xl">
<PageHeader title="계정 설정" description="표시 이름과 알림·화면 환경을 설정합니다. 비밀번호 등 보안은 Keycloak 계정에서 관리됩니다." />
<Card>
<CardHeader title="기본" action={<Button size="sm" onClick={() => save.mutate()} disabled={save.isPending}></Button>} />
<div className="p-5 grid grid-cols-2 gap-4">
<Field label="표시 이름"><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} /></Field>
<Field label="이메일 (변경 불가)"><Input value={member.email} disabled /></Field>
</div>
</Card>
<Card className="mt-4">
<CardHeader title="알림" subtitle="메일함에서 받을 알림 종류" />
<div className="p-5 divide-y divide-divider">
<ToggleRow label="프로젝트 알림" desc="프로젝트 추가·변경 알림" on={prefs.notifyProject} onChange={(v) => setPref("notifyProject", "spin.notify.project", v)} />
<ToggleRow label="근무 알림" desc="휴가·초과근무 승인/반려 알림" on={prefs.notifyWork} onChange={(v) => setPref("notifyWork", "spin.notify.work", v)} />
<ToggleRow label="인센티브 알림" desc="포인트 반영·정산 확정 알림" on={prefs.notifyIncentive} onChange={(v) => setPref("notifyIncentive", "spin.notify.incentive", v)} />
</div>
</Card>
<Card className="mt-4">
<CardHeader title="화면" />
<div className="p-5 divide-y divide-divider">
<ToggleRow label="사이드바 기본 접힘" desc="다음 접속부터 메뉴를 접은 상태로 시작" on={prefs.sidebarCollapsed} onChange={(v) => setPref("sidebarCollapsed", "spin.sidebarCollapsed", v)} />
</div>
</Card>
<Card className="mt-4">
<CardHeader title="보안 · 세션" />
<div className="p-5 space-y-4">
<div className="flex items-start gap-3 text-sm text-ink-secondary bg-canvas rounded-control p-4">
<ShieldCheck size={18} className="text-navy mt-0.5 shrink-0" />
<p> ·2 / <span className="font-semibold text-ink">Keycloak </span> .</p>
</div>
<Button variant="danger" icon={<LogOut size={16} />} onClick={() => logout()}></Button>
</div>
</Card>
</div>
);
}
function ToggleRow({ label, desc, on, onChange }: { label: string; desc?: string; on: boolean; onChange: (v: boolean) => void }) {
return (
<div className="flex items-center justify-between py-3 first:pt-0 last:pb-0">
<div>
<div className="text-sm font-medium text-ink">{label}</div>
{desc && <div className="text-xs text-ink-muted mt-0.5">{desc}</div>}
</div>
<button
role="switch" aria-checked={on} onClick={() => onChange(!on)}
className={classNames("relative w-10 h-6 rounded-pill transition-colors shrink-0", on ? "bg-navy" : "bg-border-strong")}
>
<span className={classNames("absolute top-0.5 w-5 h-5 bg-white rounded-full shadow transition-all", on ? "left-[18px]" : "left-0.5")} />
</button>
</div>
);
}