개선관리 요구사항과 Google 로그인을 완료

This commit is contained in:
Yun Chan 2026-08-28 16:07:09 +09:00
parent cc0a15b7c6
commit 2a39636163
112 changed files with 10166 additions and 527 deletions

View file

@ -29,10 +29,14 @@ import {
} from "../components/ui";
import {
adminApi,
adminProtocolsApi,
adminUsersApi,
type AdminHealthResponse,
type AdminHealthStatus,
type AdminManagedUser,
type AdminProtocol,
type AdminProtocolCreateRequest,
type AdminProtocolStatus,
type AdminTicketFilters,
type AdminSupportTicket,
type AdminTicketsResponse,
@ -58,6 +62,7 @@ type UserDraft = Pick<
| "display_name"
| "role"
| "admin_access"
| "learner_feedback_enabled"
| "account_status"
| "affiliation"
| "cohort_ids"
@ -67,10 +72,17 @@ type NewUserDraft = Required<
> &
Pick<
UserDraft,
"admin_access" | "account_status" | "affiliation" | "cohort_ids"
>;
| "admin_access"
| "learner_feedback_enabled"
| "affiliation"
| "cohort_ids"
> & {
account_status: "pending";
};
type UserTab = "approval" | "manage" | "register" | "activity";
type AccessTab = "roles" | "groups" | "matrix";
type AccessTab = "roles" | "groups" | "matrix" | "protocols";
type ProtocolStatusFilter = AdminProtocolStatus | "all";
type ProtocolDraft = AdminProtocolCreateRequest;
type TicketStatusFilter = AdminSupportTicket["status"] | "all";
type TicketCategoryFilter = AdminSupportTicket["category"] | "all";
type TicketPriorityFilter = AdminSupportTicket["priority"] | "all";
@ -116,11 +128,46 @@ const EMPTY_NEW_USER: NewUserDraft = {
display_name: "",
role: "learner",
admin_access: false,
account_status: "approved",
learner_feedback_enabled: true,
account_status: "pending",
affiliation: "",
cohort_ids: [],
};
const EMPTY_PROTOCOL_DRAFT: ProtocolDraft = {
title: "",
source: "",
version: 1,
license: "B",
external_llm_ok: false,
content: "",
};
function protocolStatusLabel(status: AdminProtocolStatus): string {
if (status === "draft") return "초안";
if (status === "active") return "활성";
return "퇴역";
}
function protocolStatusTone(
status: AdminProtocolStatus,
): "warn" | "pos" | "neutral" {
if (status === "draft") return "warn";
if (status === "active") return "pos";
return "neutral";
}
function protocolAllowsExternalLlm(license: ProtocolDraft["license"]): boolean {
return license === "A" || license === "B";
}
function protocolVersionError(version: number): string | null {
if (!Number.isInteger(version) || version < 1 || version > 1_000_000) {
return "버전은 1부터 1,000,000 사이의 정수여야 합니다.";
}
return null;
}
const ROLE_POLICIES = [
{
role: "관리자",
@ -516,6 +563,7 @@ function userDraftFrom(user: AdminManagedUser): UserDraft {
display_name: user.display_name,
role: user.role,
admin_access: user.admin_access,
learner_feedback_enabled: user.learner_feedback_enabled ?? true,
account_status: user.account_status,
affiliation: user.affiliation,
cohort_ids: user.cohort_ids,
@ -827,6 +875,18 @@ export default function Admin({ section = "overview" }: AdminProps) {
const [ticketStaleOnly, setTicketStaleOnly] = useState(false);
const [userTab, setUserTab] = useState<UserTab>("approval");
const [accessTab, setAccessTab] = useState<AccessTab>("roles");
const [protocols, setProtocols] = useState<AdminProtocol[]>([]);
const [protocolsLoaded, setProtocolsLoaded] = useState(false);
const [protocolsLoading, setProtocolsLoading] = useState(false);
const [protocolsError, setProtocolsError] = useState<string | null>(null);
const [protocolDraft, setProtocolDraft] = useState<ProtocolDraft>(
EMPTY_PROTOCOL_DRAFT,
);
const [protocolSaving, setProtocolSaving] = useState(false);
const [protocolActionId, setProtocolActionId] = useState<string | null>(null);
const [protocolSearch, setProtocolSearch] = useState("");
const [protocolStatusFilter, setProtocolStatusFilter] =
useState<ProtocolStatusFilter>("all");
const canGrantAdminAccess = currentUser?.superAdmin === true;
const loadHealth = useCallback(async () => {
@ -940,6 +1000,24 @@ export default function Admin({ section = "overview" }: AdminProps) {
ticketStatusFilter,
]);
const loadProtocols = useCallback(async () => {
setProtocolsLoading(true);
setProtocolsError(null);
try {
const response = await adminProtocolsApi.list();
setProtocols(response.protocols);
setProtocolsLoaded(true);
} catch (err) {
setProtocolsError(
err instanceof Error
? err.message
: "상담 프로토콜 목록을 불러오지 못했습니다.",
);
} finally {
setProtocolsLoading(false);
}
}, []);
useEffect(() => {
void loadHealth();
void loadUsers();
@ -951,6 +1029,12 @@ export default function Admin({ section = "overview" }: AdminProps) {
void loadTickets();
}, [loadTickets]);
useEffect(() => {
if (section === "access" && accessTab === "protocols" && !protocolsLoaded) {
void loadProtocols();
}
}, [accessTab, loadProtocols, protocolsLoaded, section]);
const refreshAll = useCallback(async () => {
await Promise.all([
loadHealth(),
@ -961,6 +1045,109 @@ export default function Admin({ section = "overview" }: AdminProps) {
]);
}, [loadHealth, loadTickets, loadUptime, loadUsage, loadUsers]);
const visibleProtocols = useMemo(() => {
const query = protocolSearch.trim().toLocaleLowerCase("ko-KR");
return protocols.filter((protocol) => {
if (
protocolStatusFilter !== "all" &&
protocol.status !== protocolStatusFilter
) {
return false;
}
if (!query) return true;
return [protocol.title, protocol.source, protocol.source_id]
.join(" ")
.toLocaleLowerCase("ko-KR")
.includes(query);
});
}, [protocolSearch, protocolStatusFilter, protocols]);
const createProtocol = async () => {
if (
!protocolDraft.title.trim() ||
!protocolDraft.source.trim() ||
!protocolDraft.content.trim()
) {
setProtocolsError("제목, 출처, 원문을 모두 입력해야 합니다.");
return;
}
const versionError = protocolVersionError(protocolDraft.version);
if (versionError) {
setProtocolsError(versionError);
return;
}
setProtocolSaving(true);
setProtocolsError(null);
try {
const created = await adminProtocolsApi.create({
...protocolDraft,
title: protocolDraft.title.trim(),
source: protocolDraft.source.trim(),
content: protocolDraft.content.trim(),
external_llm_ok:
protocolAllowsExternalLlm(protocolDraft.license)
? protocolDraft.external_llm_ok
: false,
});
setProtocols((current) => [
created,
...current.filter((item) => item.protocol_id !== created.protocol_id),
]);
setProtocolDraft(EMPTY_PROTOCOL_DRAFT);
setProtocolsLoaded(true);
} catch (err) {
setProtocolsError(
err instanceof Error
? err.message
: "상담 프로토콜 초안을 등록하지 못했습니다.",
);
} finally {
setProtocolSaving(false);
}
};
const activateProtocol = async (protocol: AdminProtocol) => {
setProtocolActionId(protocol.protocol_id);
setProtocolsError(null);
try {
const response = await adminProtocolsApi.activate(protocol.protocol_id);
setProtocols((current) =>
current.map((item) =>
item.protocol_id === protocol.protocol_id ? response.protocol : item,
),
);
} catch (err) {
setProtocolsError(
err instanceof Error
? err.message
: `${protocol.title}” 프로토콜을 활성화하지 못했습니다.`,
);
} finally {
setProtocolActionId(null);
}
};
const retireProtocol = async (protocol: AdminProtocol) => {
setProtocolActionId(protocol.protocol_id);
setProtocolsError(null);
try {
const retired = await adminProtocolsApi.retire(protocol.protocol_id);
setProtocols((current) =>
current.map((item) =>
item.protocol_id === protocol.protocol_id ? retired : item,
),
);
} catch (err) {
setProtocolsError(
err instanceof Error
? err.message
: `${protocol.title}” 프로토콜을 퇴역하지 못했습니다.`,
);
} finally {
setProtocolActionId(null);
}
};
const updateDraft = (userId: string, patch: Partial<UserDraft>) => {
setUserDrafts((current) => ({
...current,
@ -969,6 +1156,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
display_name: "",
role: "learner",
admin_access: false,
learner_feedback_enabled: true,
account_status: "approved",
affiliation: "",
cohort_ids: [],
@ -1017,6 +1205,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
display_name: displayName,
affiliation: newUser.affiliation.trim(),
cohort_ids: newUser.cohort_ids,
account_status: "pending",
});
setNewUser(EMPTY_NEW_USER);
await loadUsers();
@ -1388,6 +1577,31 @@ export default function Admin({ section = "overview" }: AdminProps) {
);
},
},
{
id: "learner_feedback_enabled",
accessorFn: (user) => Number(user.learner_feedback_enabled),
header: "AI 피드백",
cell: ({ row }) => {
const user = row.original;
const draft = userDrafts[user.user_id] ?? user;
return (
<label className="vgops-user-table__check">
<input
type="checkbox"
checked={draft.learner_feedback_enabled}
onChange={(event) =>
updateDraft(user.user_id, {
learner_feedback_enabled: event.target.checked,
})
}
disabled={!usersWritable || savingUserId === user.user_id}
aria-label={`${user.email} 학습자 AI 피드백`}
/>
<span>{draft.learner_feedback_enabled ? "켜짐" : "꺼짐"}</span>
</label>
);
},
},
{
id: "affiliation",
accessorFn: (user) => user.affiliation,
@ -1465,6 +1679,8 @@ export default function Admin({ section = "overview" }: AdminProps) {
draft.display_name !== user.display_name ||
draft.role !== user.role ||
draft.admin_access !== user.admin_access ||
draft.learner_feedback_enabled !==
(user.learner_feedback_enabled ?? true) ||
draft.account_status !== user.account_status ||
draft.affiliation !== user.affiliation ||
cohortInputValue(draft.cohort_ids) !==
@ -1868,7 +2084,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
`가입 승인${accountCounts.pending ? ` ${accountCounts.pending}` : ""}`,
],
["manage", "사용자 목록"],
["register", "사용자 등록"],
["register", "외부 연구참여자 사전등록"],
["activity", "활동 요약"],
]}
value={userTab}
@ -2012,12 +2228,13 @@ export default function Admin({ section = "overview" }: AdminProps) {
const renderUserCreate = () => (
<section className={surfaceClassName("vgops-panel")}>
<div className="vgops-section__head">
<h2> </h2>
<h2> </h2>
<span>{usersWritable ? "DB 저장 가능" : "읽기 전용"}</span>
</div>
<div className="vgops-users-note">
.
.
Google·
. ,
.
</div>
{!usersWritable ? (
<div className="vgops-users-note vgops-users-note--warn" role="alert">
@ -2034,6 +2251,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
<label>
<span></span>
<input
type="email"
value={newUser.email}
onChange={(event) =>
setNewUser((current) => ({
@ -2041,7 +2259,8 @@ export default function Admin({ section = "overview" }: AdminProps) {
email: event.target.value,
}))
}
placeholder="name@example.com"
placeholder="participant@example.com"
autoComplete="email"
disabled={!usersWritable || creatingUser}
aria-label="새 사용자 이메일"
/>
@ -2100,24 +2319,30 @@ export default function Admin({ section = "overview" }: AdminProps) {
/>
<span> </span>
</label>
<label>
<span> </span>
<select
value={newUser.account_status}
<label className="vgops-checkline">
<input
type="checkbox"
checked={newUser.learner_feedback_enabled}
onChange={(event) =>
setNewUser((current) => ({
...current,
account_status: event.target
.value as AdminManagedUser["account_status"],
learner_feedback_enabled: event.target.checked,
}))
}
disabled={!usersWritable || creatingUser}
aria-label="새 사용자 학습자 AI 피드백"
/>
<span> AI </span>
</label>
<label>
<span> </span>
<output
className="vgops-readonly-control"
aria-label="새 사용자 승인 상태"
data-value="pending"
>
<option value="approved"></option>
<option value="pending"> </option>
<option value="suspended"></option>
</select>
</output>
</label>
<label>
<span></span>
@ -2154,7 +2379,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
leading={<Icon name="users" size={15} />}
disabled={!usersWritable || creatingUser}
>
{creatingUser ? "등록 중" : "사용자 등록"}
{creatingUser ? "사전등록 중" : "연구참여자 사전등록"}
</Button>
</form>
</section>
@ -2354,23 +2579,303 @@ export default function Admin({ section = "overview" }: AdminProps) {
</section>
);
const renderProtocols = () => (
<section className="vgops-protocols" aria-label="상담 프로토콜 관리">
<section className={surfaceClassName("vgops-panel vgops-protocol-create") }>
<div className="vgops-section__head">
<h2> </h2>
<Badge tone="neutral"> </Badge>
</div>
<p className="vgops-protocol-intro">
RAG에 , C/D LLM에
.
</p>
<form
className="vgops-protocol-form"
onSubmit={(event) => {
event.preventDefault();
void createProtocol();
}}
>
<label>
<span></span>
<input
required
value={protocolDraft.title}
onChange={(event) =>
setProtocolDraft((current) => ({
...current,
title: event.target.value,
}))
}
aria-label="프로토콜 제목"
disabled={protocolSaving}
/>
</label>
<label className="vgops-protocol-form__source">
<span></span>
<input
required
value={protocolDraft.source}
onChange={(event) =>
setProtocolDraft((current) => ({
...current,
source: event.target.value,
}))
}
placeholder="공식 문서 URL 또는 출처 식별자"
aria-label="프로토콜 출처"
disabled={protocolSaving}
/>
</label>
<label>
<span></span>
<input
type="number"
min={1}
max={1_000_000}
required
value={protocolDraft.version}
onChange={(event) =>
setProtocolDraft((current) => ({
...current,
version: Number(event.target.value),
}))
}
aria-label="프로토콜 버전"
aria-invalid={protocolVersionError(protocolDraft.version) !== null}
aria-describedby="protocol-version-error"
disabled={protocolSaving}
/>
{protocolVersionError(protocolDraft.version) ? (
<small
id="protocol-version-error"
className="vgops-protocol-field-error"
role="alert"
>
{protocolVersionError(protocolDraft.version)}
</small>
) : null}
</label>
<label>
<span></span>
<select
value={protocolDraft.license}
onChange={(event) => {
const license = event.target.value as ProtocolDraft["license"];
setProtocolDraft((current) => ({
...current,
license,
external_llm_ok:
protocolAllowsExternalLlm(license)
? current.external_llm_ok
: false,
}));
}}
aria-label="프로토콜 라이선스"
disabled={protocolSaving}
>
{(["A", "B", "C", "D"] as const).map((license) => (
<option value={license} key={license}>
{license}
</option>
))}
</select>
</label>
<label className="vgops-protocol-check">
<input
type="checkbox"
checked={protocolDraft.external_llm_ok}
onChange={(event) =>
setProtocolDraft((current) => ({
...current,
external_llm_ok: event.target.checked,
}))
}
aria-label="외부 LLM 사용 허용"
disabled={
protocolSaving ||
protocolDraft.license === "C" ||
protocolDraft.license === "D"
}
/>
<span> LLM </span>
</label>
<p className="vgops-protocol-policy" aria-live="polite">
{protocolDraft.license === "C" || protocolDraft.license === "D"
? "라이선스 C/D는 정책상 외부 LLM 사용이 차단됩니다."
: "A/B도 명시적으로 허용한 경우에만 외부 LLM에 전달됩니다."}
</p>
<label className="vgops-protocol-form__content">
<span> </span>
<textarea
required
value={protocolDraft.content}
onChange={(event) =>
setProtocolDraft((current) => ({
...current,
content: event.target.value,
}))
}
aria-label="프로토콜 원문"
disabled={protocolSaving}
/>
</label>
<Button
className="vgops-protocol-submit"
type="submit"
disabled={protocolSaving}
leading={<Icon name="plus" size={16} />}
>
{protocolSaving ? "초안 등록 중" : "초안 등록"}
</Button>
</form>
</section>
<section className={surfaceClassName("vgops-panel vgops-protocol-list") }>
<div className="vgops-section__head">
<h2> </h2>
<span>{protocols.length}</span>
</div>
<div className="vgops-protocol-toolbar">
<label>
<span></span>
<input
type="search"
value={protocolSearch}
onChange={(event) => setProtocolSearch(event.target.value)}
placeholder="제목 또는 출처"
aria-label="프로토콜 검색"
/>
</label>
<label>
<span></span>
<select
value={protocolStatusFilter}
onChange={(event) =>
setProtocolStatusFilter(event.target.value as ProtocolStatusFilter)
}
aria-label="프로토콜 상태 필터"
>
<option value="all"></option>
<option value="draft"></option>
<option value="active"></option>
<option value="retired"></option>
</select>
</label>
<Button
variant="secondary"
onClick={() => void loadProtocols()}
disabled={protocolsLoading}
>
{protocolsLoading ? "불러오는 중" : "새로고침"}
</Button>
</div>
{protocolsError ? (
<div className="vgops-protocol-error">
<InlineError message={protocolsError} />
<Button
variant="secondary"
onClick={() => void loadProtocols()}
disabled={protocolsLoading}
>
</Button>
</div>
) : null}
{protocolsLoading && !protocolsLoaded ? (
<div className="vgops-users-note" role="status">
.
</div>
) : null}
{!protocolsLoading && protocolsLoaded && visibleProtocols.length === 0 ? (
<EmptyState
title="조건에 맞는 프로토콜이 없습니다"
body="새 초안을 등록하거나 검색·상태 필터를 바꿔 주세요."
/>
) : null}
<div className="vgops-protocol-cards">
{visibleProtocols.map((protocol) => (
<article
className={surfaceClassName("vgops-protocol-card", {
variant: "inset",
})}
key={protocol.protocol_id}
data-protocol-status={protocol.status}
>
<div className="vgops-protocol-card__copy">
<div className="vgops-protocol-card__title">
<h3>{protocol.title}</h3>
<Badge tone={protocolStatusTone(protocol.status)}>
{protocolStatusLabel(protocol.status)}
</Badge>
</div>
<p>{protocol.source}</p>
<div className="vgops-chip-row">
<span>v{protocol.version}</span>
<span> {protocol.license}</span>
<span>
LLM {protocol.external_llm_ok ? "허용" : "차단"}
</span>
</div>
</div>
<div className="vgops-protocol-card__actions">
{protocol.status === "draft" ? (
<Button
onClick={() => void activateProtocol(protocol)}
disabled={protocolActionId === protocol.protocol_id}
aria-label={`${protocol.title} 활성화`}
>
{protocolActionId === protocol.protocol_id
? "활성화 중"
: "활성화"}
</Button>
) : null}
{protocol.status === "active" ? (
<Button
variant="danger"
onClick={() => void retireProtocol(protocol)}
disabled={protocolActionId === protocol.protocol_id}
aria-label={`${protocol.title} 퇴역`}
>
{protocolActionId === protocol.protocol_id ? "퇴역 중" : "퇴역"}
</Button>
) : null}
{protocol.status === "retired" ? (
<span className="vgops-protocol-card__terminal"> </span>
) : null}
</div>
</article>
))}
</div>
</section>
</section>
);
const renderAccess = () => (
<>
{/* eyebrow "접근 권한"은 아래 "역할, 그룹, 접근 범위"와 같은 말이라 제거 */}
<PageHeader
title="역할, 그룹, 접근 범위"
description="운영자가 누구에게 어떤 작업 권한을 줄지 판단하는 정책 화면입니다."
description="운영 권한 정책과 평가용 상담 프로토콜의 수명주기를 관리합니다."
/>
<div className="vgops-users-note">
· API는 .
.
</div>
{accessTab !== "protocols" ? (
<div className="vgops-users-note">
· API는 .
.
</div>
) : null}
<TabBar
ariaLabel="접근 권한 탭"
items={[
["roles", "역할"],
["groups", "그룹"],
["matrix", "권한 매트릭스"],
["protocols", "상담 프로토콜"],
]}
value={accessTab}
onChange={setAccessTab}
@ -2447,6 +2952,8 @@ export default function Admin({ section = "overview" }: AdminProps) {
</div>
</section>
) : null}
{accessTab === "protocols" ? renderProtocols() : null}
</>
);