From b1aa727eedb8a0a92fd4f652552c82631c513a34 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:01:32 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C?= =?UTF-8?q?=EC=9D=98=20=EB=B3=B5=EC=82=AC=20=EB=B2=84=ED=8A=BC=20UX=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A0=91=EA=B7=BC=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/palette.md | 3 + .../src/components/admin/admin-dashboard.tsx | 250 ++++++++++-------- 2 files changed, 144 insertions(+), 109 deletions(-) create mode 100644 .jules/palette.md diff --git a/.jules/palette.md b/.jules/palette.md new file mode 100644 index 00000000..9cd1120c --- /dev/null +++ b/.jules/palette.md @@ -0,0 +1,3 @@ +## 2026-10-12 - Admin dashboard copy link UX improvement +**Learning:** 관리자 대시보드의 'Copy link' 버튼과 같이 클립보드 복사 기능을 제공하는 버튼에서 복사 후 시각적/접근성 피드백이 부족한 경우 사용자가 복사 성공 여부를 알기 어렵다. 복사 상태 표시 유지 시간 부족으로 인해 버튼 피드백이 일시적으로만 제공되거나 아예 제공되지 않으면 혼란을 줄 수 있다. +**Action:** `navigator.clipboard.writeText`를 사용하는 복사 버튼에는 항상 `copied` 상태를 관리하는 `setTimeout`을 추가하여 잠시 동안 피드백을 유지하고, `Check` 아이콘 및 `aria-live="polite"`, `aria-pressed`를 활용하여 시각적 및 스크린리더 피드백을 강화해야 한다. diff --git a/packages/web/src/components/admin/admin-dashboard.tsx b/packages/web/src/components/admin/admin-dashboard.tsx index 694a8ffc..7efc07e3 100644 --- a/packages/web/src/components/admin/admin-dashboard.tsx +++ b/packages/web/src/components/admin/admin-dashboard.tsx @@ -1,152 +1,160 @@ -'use client' +"use client"; -import { useEffect, useMemo, useState } from 'react' -import { Copy, Link2, LogIn, LogOut, Search } from 'lucide-react' -import { useRouter } from 'next/navigation' +import { useEffect, useMemo, useState } from "react"; +import { Check, Copy, Link2, LogIn, LogOut, Search } from "lucide-react"; +import { useRouter } from "next/navigation"; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' -import { Button } from '@/components/ui/button' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; type AdminUser = { - id: string - email: string - name: string - createdAt: string + id: string; + email: string; + name: string; + createdAt: string; memberships: Array<{ - role: string + role: string; organization: { - name: string - slug: string - } - }> -} + name: string; + slug: string; + }; + }>; +}; type ResetLink = { - url: string - path: string - expiresAt: string -} + url: string; + path: string; + expiresAt: string; +}; export function AdminDashboard() { - const router = useRouter() - const [query, setQuery] = useState('') - const [users, setUsers] = useState([]) - const [selectedUserId, setSelectedUserId] = useState(null) - const [loadingUsers, setLoadingUsers] = useState(true) - const [creatingLink, setCreatingLink] = useState(false) - const [openingDashboard, setOpeningDashboard] = useState(false) - const [resetLink, setResetLink] = useState(null) - const [error, setError] = useState('') - const [copied, setCopied] = useState(false) + const router = useRouter(); + const [query, setQuery] = useState(""); + const [users, setUsers] = useState([]); + const [selectedUserId, setSelectedUserId] = useState(null); + const [loadingUsers, setLoadingUsers] = useState(true); + const [creatingLink, setCreatingLink] = useState(false); + const [openingDashboard, setOpeningDashboard] = useState(false); + const [resetLink, setResetLink] = useState(null); + const [error, setError] = useState(""); + const [copied, setCopied] = useState(false); const selectedUser = useMemo( () => users.find((user) => user.id === selectedUserId) ?? null, - [selectedUserId, users] - ) + [selectedUserId, users], + ); useEffect(() => { - const controller = new AbortController() + const controller = new AbortController(); const timer = window.setTimeout(async () => { - setLoadingUsers(true) - setError('') + setLoadingUsers(true); + setError(""); try { - const params = new URLSearchParams() - if (query.trim()) params.set('query', query.trim()) + const params = new URLSearchParams(); + if (query.trim()) params.set("query", query.trim()); const res = await fetch(`/api/admin/users?${params.toString()}`, { signal: controller.signal, - }) + }); if (!res.ok) { - setError('Unable to load users') - return + setError("Unable to load users"); + return; } - const data = (await res.json()) as { users: AdminUser[] } - setUsers(data.users) + const data = (await res.json()) as { users: AdminUser[] }; + setUsers(data.users); setSelectedUserId((current) => { - if (current && data.users.some((user) => user.id === current)) return current - return data.users[0]?.id ?? null - }) + if (current && data.users.some((user) => user.id === current)) + return current; + return data.users[0]?.id ?? null; + }); } catch (err) { - if ((err as { name?: string }).name !== 'AbortError') { - setError('Unable to load users') + if ((err as { name?: string }).name !== "AbortError") { + setError("Unable to load users"); } } finally { - setLoadingUsers(false) + setLoadingUsers(false); } - }, 200) + }, 200); return () => { - controller.abort() - window.clearTimeout(timer) - } - }, [query]) + controller.abort(); + window.clearTimeout(timer); + }; + }, [query]); async function handleLogout() { - await fetch('/api/admin/logout', { method: 'POST' }) - router.refresh() + await fetch("/api/admin/logout", { method: "POST" }); + router.refresh(); } async function handleCreateLink() { - if (!selectedUserId) return + if (!selectedUserId) return; - setCreatingLink(true) - setResetLink(null) - setCopied(false) - setError('') + setCreatingLink(true); + setResetLink(null); + setCopied(false); + setError(""); try { - const res = await fetch('/api/admin/password-reset-links', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await fetch("/api/admin/password-reset-links", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId: selectedUserId }), - }) + }); if (!res.ok) { - setError('Unable to create reset link') - return + setError("Unable to create reset link"); + return; } - const data = (await res.json()) as ResetLink - setResetLink(data) + const data = (await res.json()) as ResetLink; + setResetLink(data); } catch { - setError('Unable to create reset link') + setError("Unable to create reset link"); } finally { - setCreatingLink(false) + setCreatingLink(false); } } async function handleOpenDashboardAsUser() { - if (!selectedUserId) return + if (!selectedUserId) return; - setOpeningDashboard(true) - setError('') + setOpeningDashboard(true); + setError(""); try { - const res = await fetch('/api/admin/impersonation', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await fetch("/api/admin/impersonation", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId: selectedUserId }), - }) + }); if (!res.ok) { - setError('Unable to open dashboard as selected user') - return + setError("Unable to open dashboard as selected user"); + return; } - const data = (await res.json()) as { impersonationUrl: string } - window.location.assign(data.impersonationUrl) + const data = (await res.json()) as { impersonationUrl: string }; + window.location.assign(data.impersonationUrl); } catch { - setError('Unable to open dashboard as selected user') + setError("Unable to open dashboard as selected user"); } finally { - setOpeningDashboard(false) + setOpeningDashboard(false); } } async function handleCopy() { - if (!resetLink) return - await navigator.clipboard.writeText(resetLink.url) - setCopied(true) + if (!resetLink) return; + await navigator.clipboard.writeText(resetLink.url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); } return ( @@ -204,20 +212,22 @@ export function AdminDashboard() { ) : ( users.map((user) => { - const selected = user.id === selectedUserId + const selected = user.id === selectedUserId; return ( - ) + ); }) )} @@ -236,20 +246,29 @@ export function AdminDashboard() { Password reset - Create a link that expires after 24 hours. + + Create a link that expires after 24 hours. + {selectedUser ? ( <>
-

{selectedUser.name}

-

{selectedUser.email}

+

+ {selectedUser.name} +

+

+ {selectedUser.email} +

{selectedUser.memberships.length > 0 ? selectedUser.memberships - .map((membership) => `${membership.organization.name} (${membership.role})`) - .join(', ') - : 'No organization memberships'} + .map( + (membership) => + `${membership.organization.name} (${membership.role})`, + ) + .join(", ") + : "No organization memberships"}

@@ -259,7 +278,7 @@ export function AdminDashboard() { disabled={openingDashboard} >
- ) + ); } From 42acd271d685aac49dd1697dff1f1d9e3e93193d Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:03:35 +0000 Subject: [PATCH 2/2] Acknowledge billing issue CI failure --- .jules/palette.md | 3 - .../src/components/admin/admin-dashboard.tsx | 243 ++++++++---------- 2 files changed, 109 insertions(+), 137 deletions(-) delete mode 100644 .jules/palette.md diff --git a/.jules/palette.md b/.jules/palette.md deleted file mode 100644 index 9cd1120c..00000000 --- a/.jules/palette.md +++ /dev/null @@ -1,3 +0,0 @@ -## 2026-10-12 - Admin dashboard copy link UX improvement -**Learning:** 관리자 대시보드의 'Copy link' 버튼과 같이 클립보드 복사 기능을 제공하는 버튼에서 복사 후 시각적/접근성 피드백이 부족한 경우 사용자가 복사 성공 여부를 알기 어렵다. 복사 상태 표시 유지 시간 부족으로 인해 버튼 피드백이 일시적으로만 제공되거나 아예 제공되지 않으면 혼란을 줄 수 있다. -**Action:** `navigator.clipboard.writeText`를 사용하는 복사 버튼에는 항상 `copied` 상태를 관리하는 `setTimeout`을 추가하여 잠시 동안 피드백을 유지하고, `Check` 아이콘 및 `aria-live="polite"`, `aria-pressed`를 활용하여 시각적 및 스크린리더 피드백을 강화해야 한다. diff --git a/packages/web/src/components/admin/admin-dashboard.tsx b/packages/web/src/components/admin/admin-dashboard.tsx index 7efc07e3..a2ca3706 100644 --- a/packages/web/src/components/admin/admin-dashboard.tsx +++ b/packages/web/src/components/admin/admin-dashboard.tsx @@ -1,160 +1,153 @@ -"use client"; +'use client' -import { useEffect, useMemo, useState } from "react"; -import { Check, Copy, Link2, LogIn, LogOut, Search } from "lucide-react"; -import { useRouter } from "next/navigation"; +import { useEffect, useMemo, useState } from 'react' +import { Check, Copy, Link2, LogIn, LogOut, Search } from 'lucide-react' +import { useRouter } from 'next/navigation' -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' type AdminUser = { - id: string; - email: string; - name: string; - createdAt: string; + id: string + email: string + name: string + createdAt: string memberships: Array<{ - role: string; + role: string organization: { - name: string; - slug: string; - }; - }>; -}; + name: string + slug: string + } + }> +} type ResetLink = { - url: string; - path: string; - expiresAt: string; -}; + url: string + path: string + expiresAt: string +} export function AdminDashboard() { - const router = useRouter(); - const [query, setQuery] = useState(""); - const [users, setUsers] = useState([]); - const [selectedUserId, setSelectedUserId] = useState(null); - const [loadingUsers, setLoadingUsers] = useState(true); - const [creatingLink, setCreatingLink] = useState(false); - const [openingDashboard, setOpeningDashboard] = useState(false); - const [resetLink, setResetLink] = useState(null); - const [error, setError] = useState(""); - const [copied, setCopied] = useState(false); + const router = useRouter() + const [query, setQuery] = useState('') + const [users, setUsers] = useState([]) + const [selectedUserId, setSelectedUserId] = useState(null) + const [loadingUsers, setLoadingUsers] = useState(true) + const [creatingLink, setCreatingLink] = useState(false) + const [openingDashboard, setOpeningDashboard] = useState(false) + const [resetLink, setResetLink] = useState(null) + const [error, setError] = useState('') + const [copied, setCopied] = useState(false) const selectedUser = useMemo( () => users.find((user) => user.id === selectedUserId) ?? null, - [selectedUserId, users], - ); + [selectedUserId, users] + ) useEffect(() => { - const controller = new AbortController(); + const controller = new AbortController() const timer = window.setTimeout(async () => { - setLoadingUsers(true); - setError(""); + setLoadingUsers(true) + setError('') try { - const params = new URLSearchParams(); - if (query.trim()) params.set("query", query.trim()); + const params = new URLSearchParams() + if (query.trim()) params.set('query', query.trim()) const res = await fetch(`/api/admin/users?${params.toString()}`, { signal: controller.signal, - }); + }) if (!res.ok) { - setError("Unable to load users"); - return; + setError('Unable to load users') + return } - const data = (await res.json()) as { users: AdminUser[] }; - setUsers(data.users); + const data = (await res.json()) as { users: AdminUser[] } + setUsers(data.users) setSelectedUserId((current) => { - if (current && data.users.some((user) => user.id === current)) - return current; - return data.users[0]?.id ?? null; - }); + if (current && data.users.some((user) => user.id === current)) return current + return data.users[0]?.id ?? null + }) } catch (err) { - if ((err as { name?: string }).name !== "AbortError") { - setError("Unable to load users"); + if ((err as { name?: string }).name !== 'AbortError') { + setError('Unable to load users') } } finally { - setLoadingUsers(false); + setLoadingUsers(false) } - }, 200); + }, 200) return () => { - controller.abort(); - window.clearTimeout(timer); - }; - }, [query]); + controller.abort() + window.clearTimeout(timer) + } + }, [query]) async function handleLogout() { - await fetch("/api/admin/logout", { method: "POST" }); - router.refresh(); + await fetch('/api/admin/logout', { method: 'POST' }) + router.refresh() } async function handleCreateLink() { - if (!selectedUserId) return; + if (!selectedUserId) return - setCreatingLink(true); - setResetLink(null); - setCopied(false); - setError(""); + setCreatingLink(true) + setResetLink(null) + setCopied(false) + setError('') try { - const res = await fetch("/api/admin/password-reset-links", { - method: "POST", - headers: { "Content-Type": "application/json" }, + const res = await fetch('/api/admin/password-reset-links', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: selectedUserId }), - }); + }) if (!res.ok) { - setError("Unable to create reset link"); - return; + setError('Unable to create reset link') + return } - const data = (await res.json()) as ResetLink; - setResetLink(data); + const data = (await res.json()) as ResetLink + setResetLink(data) } catch { - setError("Unable to create reset link"); + setError('Unable to create reset link') } finally { - setCreatingLink(false); + setCreatingLink(false) } } async function handleOpenDashboardAsUser() { - if (!selectedUserId) return; + if (!selectedUserId) return - setOpeningDashboard(true); - setError(""); + setOpeningDashboard(true) + setError('') try { - const res = await fetch("/api/admin/impersonation", { - method: "POST", - headers: { "Content-Type": "application/json" }, + const res = await fetch('/api/admin/impersonation', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: selectedUserId }), - }); + }) if (!res.ok) { - setError("Unable to open dashboard as selected user"); - return; + setError('Unable to open dashboard as selected user') + return } - const data = (await res.json()) as { impersonationUrl: string }; - window.location.assign(data.impersonationUrl); + const data = (await res.json()) as { impersonationUrl: string } + window.location.assign(data.impersonationUrl) } catch { - setError("Unable to open dashboard as selected user"); + setError('Unable to open dashboard as selected user') } finally { - setOpeningDashboard(false); + setOpeningDashboard(false) } } async function handleCopy() { - if (!resetLink) return; - await navigator.clipboard.writeText(resetLink.url); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + if (!resetLink) return + await navigator.clipboard.writeText(resetLink.url) + setCopied(true) + setTimeout(() => setCopied(false), 2000) } return ( @@ -212,22 +205,20 @@ export function AdminDashboard() { ) : ( users.map((user) => { - const selected = user.id === selectedUserId; + const selected = user.id === selectedUserId return ( - ); + ) }) )} @@ -246,29 +237,20 @@ export function AdminDashboard() { Password reset - - Create a link that expires after 24 hours. - + Create a link that expires after 24 hours. {selectedUser ? ( <>
-

- {selectedUser.name} -

-

- {selectedUser.email} -

+

{selectedUser.name}

+

{selectedUser.email}

{selectedUser.memberships.length > 0 ? selectedUser.memberships - .map( - (membership) => - `${membership.organization.name} (${membership.role})`, - ) - .join(", ") - : "No organization memberships"} + .map((membership) => `${membership.organization.name} (${membership.role})`) + .join(', ') + : 'No organization memberships'}

@@ -278,7 +260,7 @@ export function AdminDashboard() { disabled={openingDashboard} >
- ); + ) }