wing-ops/frontend/src/components/views/AdminView.tsx
htlee 7743e40767 feat(auth): Google OAuth 로그인 연동
- google-auth-library로 Google ID Token 검증 (backend)
- @react-oauth/google GoogleLogin 컴포넌트 (frontend)
- gcsc.co.kr 도메인 자동 승인(ACTIVE), 기타 도메인 PENDING
- 기존 ID/PW 사용자와 OAuth 사용자 동일 계정 체계 통합
- AdminView: 사용자 인증방식(Google/ID PW) 뱃지 표시
- AdminView: OAuth 자동 승인 도메인 설정 UI
- deploy.yml: VITE_GOOGLE_CLIENT_ID 빌드 환경변수 추가
- nginx: Cross-Origin-Opener-Policy 헤더 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 16:42:59 +09:00

759 lines
35 KiB
TypeScript
Executable File

import { useState, useEffect, useCallback } from 'react'
import { useSubMenu } from '../../hooks/useSubMenu'
import {
fetchUsers,
fetchRoles,
updatePermissionsApi,
updateUserApi,
updateRoleDefaultApi,
approveUserApi,
rejectUserApi,
fetchRegistrationSettings,
updateRegistrationSettingsApi,
fetchOAuthSettings,
updateOAuthSettingsApi,
type UserListItem,
type RoleWithPermissions,
type RegistrationSettings,
type OAuthSettings,
} from '../../services/authApi'
const roleLabels: Record<string, { label: string; color: string }> = {
ADMIN: { label: '관리자', color: 'var(--red)' },
MANAGER: { label: '매니저', color: 'var(--orange)' },
USER: { label: '사용자', color: 'var(--cyan)' },
VIEWER: { label: '뷰어', color: 'var(--t3)' },
}
const statusLabels: Record<string, { label: string; color: string; dot: string }> = {
PENDING: { label: '승인대기', color: 'text-yellow-400', dot: 'bg-yellow-400' },
ACTIVE: { label: '활성', color: 'text-green-400', dot: 'bg-green-400' },
LOCKED: { label: '잠김', color: 'text-red-400', dot: 'bg-red-400' },
INACTIVE: { label: '비활성', color: 'text-text-3', dot: 'bg-text-3' },
REJECTED: { label: '거절됨', color: 'text-red-300', dot: 'bg-red-300' },
}
const mockMenus = [
{ id: 'prediction', label: '유출유 확산예측', enabled: true, order: 1 },
{ id: 'hns', label: 'HNS·대기확산', enabled: true, order: 2 },
{ id: 'rescue', label: '긴급구난', enabled: true, order: 3 },
{ id: 'reports', label: '보고자료', enabled: true, order: 4 },
{ id: 'aerial', label: '항공탐색', enabled: true, order: 5 },
{ id: 'assets', label: '방제자산 관리', enabled: true, order: 6 },
{ id: 'scat', label: 'Pre-SCAT', enabled: false, order: 7 },
{ id: 'incidents', label: '사고조회', enabled: true, order: 8 },
{ id: 'board', label: '게시판', enabled: true, order: 9 },
{ id: 'weather', label: '기상정보', enabled: true, order: 10 },
]
// ─── 사용자 관리 패널 ─────────────────────────────────────────
function UsersPanel() {
const [searchTerm, setSearchTerm] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('')
const [users, setUsers] = useState<UserListItem[]>([])
const [loading, setLoading] = useState(true)
const loadUsers = useCallback(async () => {
setLoading(true)
try {
const data = await fetchUsers(searchTerm || undefined, statusFilter || undefined)
setUsers(data)
} catch (err) {
console.error('사용자 목록 조회 실패:', err)
} finally {
setLoading(false)
}
}, [searchTerm, statusFilter])
useEffect(() => {
loadUsers()
}, [loadUsers])
const handleUnlock = async (userId: string) => {
try {
await updateUserApi(userId, { status: 'ACTIVE' })
await loadUsers()
} catch (err) {
console.error('계정 잠금 해제 실패:', err)
}
}
const handleApprove = async (userId: string) => {
try {
await approveUserApi(userId)
await loadUsers()
} catch (err) {
console.error('사용자 승인 실패:', err)
}
}
const handleReject = async (userId: string) => {
try {
await rejectUserApi(userId)
await loadUsers()
} catch (err) {
console.error('사용자 거절 실패:', err)
}
}
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('ko-KR', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
})
}
const pendingCount = users.filter(u => u.status === 'PENDING').length
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-3">
<div>
<h1 className="text-lg font-bold text-text-1 font-korean"> </h1>
<p className="text-xs text-text-3 mt-1 font-korean"> {users.length}</p>
</div>
{pendingCount > 0 && (
<span className="px-2.5 py-1 text-[10px] font-bold rounded-full bg-[rgba(250,204,21,0.15)] text-yellow-400 border border-[rgba(250,204,21,0.3)] animate-pulse font-korean">
{pendingCount}
</span>
)}
</div>
<div className="flex items-center gap-3">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
>
<option value=""> </option>
<option value="PENDING"></option>
<option value="ACTIVE"></option>
<option value="LOCKED"></option>
<option value="INACTIVE"></option>
<option value="REJECTED"></option>
</select>
<input
type="text"
placeholder="이름, 계정 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-56 px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
/>
<button className="px-4 py-2 text-xs font-semibold rounded-md bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean">
+
</button>
</div>
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean"> ...</div>
) : (
<table className="w-full">
<thead>
<tr className="border-b border-border bg-bg-1">
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"></th>
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean"> </th>
<th className="px-6 py-3 text-right text-[11px] font-semibold text-text-3 font-korean"></th>
</tr>
</thead>
<tbody>
{users.map((user) => {
const primaryRole = user.roles[0] || 'USER'
const roleInfo = roleLabels[primaryRole] || roleLabels.USER
const statusInfo = statusLabels[user.status] || statusLabels.INACTIVE
return (
<tr key={user.id} className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors">
<td className="px-6 py-3 text-[12px] text-text-1 font-semibold font-korean">{user.name}</td>
<td className="px-6 py-3 text-[12px] text-text-2 font-mono">{user.account}</td>
<td className="px-6 py-3 text-[12px] text-text-2 font-korean">{user.orgAbbr || '-'}</td>
<td className="px-6 py-3">
<span
className="px-2 py-1 text-[10px] font-semibold rounded-md font-korean"
style={{
background: `${roleInfo.color}20`,
color: roleInfo.color,
border: `1px solid ${roleInfo.color}40`
}}
>
{roleInfo.label}
</span>
</td>
<td className="px-6 py-3">
{user.oauthProvider ? (
<span
className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-semibold rounded-md font-mono"
style={{ background: 'rgba(66,133,244,0.15)', color: '#4285F4', border: '1px solid rgba(66,133,244,0.3)' }}
title={user.email || undefined}
>
<svg width="10" height="10" viewBox="0 0 48 48"><path fill="#4285F4" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/><path fill="#34A853" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/><path fill="#FBBC05" d="M10.53 28.59A14.5 14.5 0 019.5 24c0-1.59.28-3.14.76-4.59l-7.98-6.19A23.99 23.99 0 000 24c0 3.77.9 7.35 2.56 10.54l7.97-5.95z"/><path fill="#EA4335" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 5.95C6.51 42.62 14.62 48 24 48z"/></svg>
Google
</span>
) : (
<span
className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-semibold rounded-md font-korean"
style={{ background: 'rgba(148,163,184,0.15)', color: 'var(--t3)', border: '1px solid rgba(148,163,184,0.2)' }}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
ID/PW
</span>
)}
</td>
<td className="px-6 py-3">
<span className={`inline-flex items-center gap-1.5 text-[10px] font-semibold font-korean ${statusInfo.color}`}>
<span className={`w-1.5 h-1.5 rounded-full ${statusInfo.dot}`} />
{statusInfo.label}
</span>
</td>
<td className="px-6 py-3 text-[11px] text-text-3 font-mono">{formatDate(user.lastLogin)}</td>
<td className="px-6 py-3 text-right">
<div className="flex items-center justify-end gap-2">
{user.status === 'PENDING' && (
<>
<button
onClick={() => handleApprove(user.id)}
className="px-2 py-1 text-[10px] font-semibold text-green-400 border border-green-400 rounded hover:bg-[rgba(74,222,128,0.1)] transition-all font-korean"
>
</button>
<button
onClick={() => handleReject(user.id)}
className="px-2 py-1 text-[10px] font-semibold text-red-400 border border-red-400 rounded hover:bg-[rgba(248,113,113,0.1)] transition-all font-korean"
>
</button>
</>
)}
{user.status === 'LOCKED' && (
<button
onClick={() => handleUnlock(user.id)}
className="px-2 py-1 text-[10px] font-semibold text-yellow-400 border border-yellow-400 rounded hover:bg-[rgba(250,204,21,0.1)] transition-all font-korean"
>
</button>
)}
<button className="px-2 py-1 text-[10px] font-semibold text-primary-cyan border border-primary-cyan rounded hover:bg-[rgba(6,182,212,0.1)] transition-all font-korean">
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
</div>
)
}
// ─── 권한 관리 패널 ─────────────────────────────────────────
const PERM_RESOURCES = [
{ id: 'prediction', label: '유출유 확산예측', desc: '확산 예측 실행 및 결과 조회' },
{ id: 'hns', label: 'HNS·대기확산', desc: '대기확산 분석 실행 및 조회' },
{ id: 'rescue', label: '긴급구난', desc: '구난 예측 실행 및 조회' },
{ id: 'reports', label: '보고자료', desc: '보고자료 생성 및 관리' },
{ id: 'aerial', label: '항공탐색', desc: '항공탐색 계획 및 결과 조회' },
{ id: 'assets', label: '방제자산 관리', desc: '방제자산 등록 및 관리' },
{ id: 'scat', label: '해안평가', desc: '해안 SCAT 조사 접근' },
{ id: 'incidents', label: '사고조회', desc: '사고 정보 등록 및 조회' },
{ id: 'board', label: '게시판', desc: '게시판 접근' },
{ id: 'weather', label: '기상정보', desc: '기상 정보 조회' },
{ id: 'admin', label: '관리자 설정', desc: '시스템 관리 기능 접근' },
]
function PermissionsPanel() {
const [roles, setRoles] = useState<RoleWithPermissions[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [dirty, setDirty] = useState(false)
useEffect(() => {
loadRoles()
}, [])
const loadRoles = async () => {
setLoading(true)
try {
const data = await fetchRoles()
setRoles(data)
setDirty(false)
} catch (err) {
console.error('역할 목록 조회 실패:', err)
} finally {
setLoading(false)
}
}
const getPermGranted = (roleSn: number, resourceCode: string): boolean => {
const role = roles.find(r => r.sn === roleSn)
if (!role) return false
const perm = role.permissions.find(p => p.resourceCode === resourceCode)
return perm?.granted ?? false
}
const togglePerm = (roleSn: number, resourceCode: string) => {
setRoles(prev => prev.map(role => {
if (role.sn !== roleSn) return role
const perms = role.permissions.map(p =>
p.resourceCode === resourceCode ? { ...p, granted: !p.granted } : p
)
if (!perms.find(p => p.resourceCode === resourceCode)) {
perms.push({ sn: 0, resourceCode, granted: true })
}
return { ...role, permissions: perms }
}))
setDirty(true)
}
const toggleDefault = async (roleSn: number) => {
const role = roles.find(r => r.sn === roleSn)
if (!role) return
const newValue = !role.isDefault
try {
await updateRoleDefaultApi(roleSn, newValue)
setRoles(prev => prev.map(r =>
r.sn === roleSn ? { ...r, isDefault: newValue } : r
))
} catch (err) {
console.error('기본 역할 변경 실패:', err)
}
}
const handleSave = async () => {
setSaving(true)
try {
for (const role of roles) {
const permissions = PERM_RESOURCES.map(r => ({
resourceCode: r.id,
granted: getPermGranted(role.sn, r.id),
}))
await updatePermissionsApi(role.sn, permissions)
}
setDirty(false)
} catch (err) {
console.error('권한 저장 실패:', err)
} finally {
setSaving(false)
}
}
if (loading) {
return <div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean"> ...</div>
}
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div>
<h1 className="text-lg font-bold text-text-1 font-korean"> </h1>
<p className="text-xs text-text-3 mt-1 font-korean"> </p>
</div>
<button
onClick={handleSave}
disabled={!dirty || saving}
className={`px-4 py-2 text-xs font-semibold rounded-md transition-all font-korean ${
dirty ? 'bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]' : 'bg-bg-3 text-text-3 cursor-not-allowed'
}`}
>
{saving ? '저장 중...' : '변경사항 저장'}
</button>
</div>
<div className="flex-1 overflow-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-bg-1">
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean min-w-[200px]"></th>
{roles.map(role => {
const info = roleLabels[role.code] || { label: role.name, color: 'var(--t3)' }
return (
<th key={role.sn} className="px-6 py-3 text-center min-w-[100px]">
<div className="text-[11px] font-semibold font-korean" style={{ color: info.color }}>
{info.label}
</div>
<button
onClick={() => toggleDefault(role.sn)}
className={`mt-1 px-1.5 py-0.5 text-[9px] rounded transition-all font-korean ${
role.isDefault
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan border border-primary-cyan'
: 'text-text-3 border border-transparent hover:border-border'
}`}
title="신규 사용자에게 자동 할당되는 기본 역할"
>
{role.isDefault ? '기본역할' : '기본역할 설정'}
</button>
</th>
)
})}
</tr>
</thead>
<tbody>
{PERM_RESOURCES.map((perm) => (
<tr key={perm.id} className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors">
<td className="px-6 py-3">
<div className="text-[12px] text-text-1 font-semibold font-korean">{perm.label}</div>
<div className="text-[10px] text-text-3 font-korean mt-0.5">{perm.desc}</div>
</td>
{roles.map(role => (
<td key={role.sn} className="px-6 py-3 text-center">
<button
onClick={() => togglePerm(role.sn, perm.id)}
className={`w-8 h-8 rounded-md border text-sm transition-all ${
getPermGranted(role.sn, perm.id)
? 'bg-[rgba(6,182,212,0.15)] border-primary-cyan text-primary-cyan'
: 'bg-bg-2 border-border text-text-3 hover:border-text-3'
}`}
>
{getPermGranted(role.sn, perm.id) ? '✓' : '—'}
</button>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
// ─── 메뉴 관리 패널 ─────────────────────────────────────────
function MenusPanel() {
const [menus, setMenus] = useState(mockMenus)
const toggleMenu = (id: string) => {
setMenus(prev => prev.map(m => m.id === id ? { ...m, enabled: !m.enabled } : m))
}
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div>
<h1 className="text-lg font-bold text-text-1 font-korean"> </h1>
<p className="text-xs text-text-3 mt-1 font-korean"> </p>
</div>
<button className="px-4 py-2 text-xs font-semibold rounded-md bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean">
</button>
</div>
<div className="flex-1 overflow-auto px-6 py-4">
<div className="flex flex-col gap-2 max-w-[600px]">
{menus.map((menu, idx) => (
<div
key={menu.id}
className={`flex items-center justify-between px-4 py-3 rounded-md border transition-all ${
menu.enabled
? 'bg-bg-1 border-border'
: 'bg-bg-0 border-border opacity-50'
}`}
>
<div className="flex items-center gap-4">
<span className="text-text-3 text-xs font-mono w-6 text-center">{idx + 1}</span>
<div>
<div className={`text-[13px] font-semibold font-korean ${menu.enabled ? 'text-text-1' : 'text-text-3'}`}>
{menu.label}
</div>
<div className="text-[10px] text-text-3 font-mono">{menu.id}</div>
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => toggleMenu(menu.id)}
className={`relative w-10 h-5 rounded-full transition-all ${
menu.enabled ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'
}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${
menu.enabled ? 'left-[22px]' : 'left-0.5'
}`}
/>
</button>
<div className="flex gap-1">
<button
onClick={() => {
if (idx === 0) return
setMenus(prev => {
const arr = [...prev]
;[arr[idx - 1], arr[idx]] = [arr[idx], arr[idx - 1]]
return arr.map((m, i) => ({ ...m, order: i + 1 }))
})
}}
className="w-7 h-7 rounded border border-border bg-bg-2 text-text-3 text-xs flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all"
>
</button>
<button
onClick={() => {
if (idx === menus.length - 1) return
setMenus(prev => {
const arr = [...prev]
;[arr[idx], arr[idx + 1]] = [arr[idx + 1], arr[idx]]
return arr.map((m, i) => ({ ...m, order: i + 1 }))
})
}}
className="w-7 h-7 rounded border border-border bg-bg-2 text-text-3 text-xs flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all"
>
</button>
</div>
</div>
</div>
))}
</div>
</div>
</div>
)
}
// ─── 시스템 설정 패널 ────────────────────────────────────────
function SettingsPanel() {
const [settings, setSettings] = useState<RegistrationSettings | null>(null)
const [oauthSettings, setOauthSettings] = useState<OAuthSettings | null>(null)
const [oauthDomainInput, setOauthDomainInput] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [savingOAuth, setSavingOAuth] = useState(false)
useEffect(() => {
loadSettings()
}, [])
const loadSettings = async () => {
setLoading(true)
try {
const [regData, oauthData] = await Promise.all([
fetchRegistrationSettings(),
fetchOAuthSettings(),
])
setSettings(regData)
setOauthSettings(oauthData)
setOauthDomainInput(oauthData.autoApproveDomains)
} catch (err) {
console.error('설정 조회 실패:', err)
} finally {
setLoading(false)
}
}
const handleToggle = async (key: keyof RegistrationSettings) => {
if (!settings) return
const newValue = !settings[key]
setSaving(true)
try {
const updated = await updateRegistrationSettingsApi({ [key]: newValue })
setSettings(updated)
} catch (err) {
console.error('설정 변경 실패:', err)
} finally {
setSaving(false)
}
}
if (loading) {
return <div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean"> ...</div>
}
return (
<div className="flex flex-col h-full">
<div className="px-6 py-4 border-b border-border">
<h1 className="text-lg font-bold text-text-1 font-korean"> </h1>
<p className="text-xs text-text-3 mt-1 font-korean"> </p>
</div>
<div className="flex-1 overflow-auto px-6 py-6">
<div className="max-w-[640px] flex flex-col gap-6">
{/* 사용자 등록 설정 */}
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
<div className="px-5 py-3 border-b border-border">
<h2 className="text-sm font-bold text-text-1 font-korean"> </h2>
<p className="text-[11px] text-text-3 mt-0.5 font-korean"> </p>
</div>
<div className="divide-y divide-border">
{/* 자동 승인 */}
<div className="px-5 py-4 flex items-center justify-between">
<div className="flex-1 mr-4">
<div className="text-[13px] font-semibold text-text-1 font-korean"> </div>
<p className="text-[11px] text-text-3 mt-1 font-korean leading-relaxed">
<span className="text-green-400 font-semibold">ACTIVE</span> .
<span className="text-yellow-400 font-semibold">PENDING</span> .
</p>
</div>
<button
onClick={() => handleToggle('autoApprove')}
disabled={saving}
className={`relative w-12 h-6 rounded-full transition-all flex-shrink-0 ${
settings?.autoApprove ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'
} ${saving ? 'opacity-50' : ''}`}
>
<span
className={`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all ${
settings?.autoApprove ? 'left-[26px]' : 'left-0.5'
}`}
/>
</button>
</div>
{/* 기본 역할 자동 할당 */}
<div className="px-5 py-4 flex items-center justify-between">
<div className="flex-1 mr-4">
<div className="text-[13px] font-semibold text-text-1 font-korean"> </div>
<p className="text-[11px] text-text-3 mt-1 font-korean leading-relaxed">
<span className="text-primary-cyan font-semibold"> </span> .
.
</p>
</div>
<button
onClick={() => handleToggle('defaultRole')}
disabled={saving}
className={`relative w-12 h-6 rounded-full transition-all flex-shrink-0 ${
settings?.defaultRole ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'
} ${saving ? 'opacity-50' : ''}`}
>
<span
className={`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all ${
settings?.defaultRole ? 'left-[26px]' : 'left-0.5'
}`}
/>
</button>
</div>
</div>
</div>
{/* OAuth 설정 */}
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
<div className="px-5 py-3 border-b border-border">
<h2 className="text-sm font-bold text-text-1 font-korean">Google OAuth </h2>
<p className="text-[11px] text-text-3 mt-0.5 font-korean">Google </p>
</div>
<div className="px-5 py-4">
<div className="flex-1 mr-4 mb-3">
<div className="text-[13px] font-semibold text-text-1 font-korean mb-1"> </div>
<p className="text-[11px] text-text-3 font-korean leading-relaxed mb-3">
Google <span className="text-green-400 font-semibold">ACTIVE</span> .
<span className="text-yellow-400 font-semibold">PENDING</span> .
(,) .
</p>
<div className="flex gap-2">
<input
type="text"
value={oauthDomainInput}
onChange={(e) => setOauthDomainInput(e.target.value)}
placeholder="gcsc.co.kr, example.com"
className="flex-1 px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-mono"
/>
<button
onClick={async () => {
setSavingOAuth(true)
try {
const updated = await updateOAuthSettingsApi({ autoApproveDomains: oauthDomainInput.trim() })
setOauthSettings(updated)
setOauthDomainInput(updated.autoApproveDomains)
} catch (err) {
console.error('OAuth 설정 변경 실패:', err)
} finally {
setSavingOAuth(false)
}
}}
disabled={savingOAuth || oauthDomainInput.trim() === (oauthSettings?.autoApproveDomains || '')}
className={`px-4 py-2 text-xs font-semibold rounded-md transition-all font-korean whitespace-nowrap ${
oauthDomainInput.trim() !== (oauthSettings?.autoApproveDomains || '')
? 'bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
: 'bg-bg-3 text-text-3 cursor-not-allowed'
}`}
>
{savingOAuth ? '저장 중...' : '저장'}
</button>
</div>
</div>
{oauthSettings?.autoApproveDomains && (
<div className="flex flex-wrap gap-1.5 mt-3">
{oauthSettings.autoApproveDomains.split(',').map(d => d.trim()).filter(Boolean).map(domain => (
<span
key={domain}
className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-mono rounded-md"
style={{ background: 'rgba(6,182,212,0.1)', color: 'var(--cyan)', border: '1px solid rgba(6,182,212,0.25)' }}
>
@{domain}
</span>
))}
</div>
)}
</div>
</div>
{/* 현재 설정 상태 요약 */}
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
<div className="px-5 py-3 border-b border-border">
<h2 className="text-sm font-bold text-text-1 font-korean"> </h2>
</div>
<div className="px-5 py-4">
<div className="flex flex-col gap-3 text-[12px] font-korean">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${settings?.autoApprove ? 'bg-green-400' : 'bg-yellow-400'}`} />
<span className="text-text-2">
{' '}
{settings?.autoApprove ? (
<span className="text-green-400 font-semibold"> </span>
) : (
<span className="text-yellow-400 font-semibold"> </span>
)}
</span>
</div>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${settings?.defaultRole ? 'bg-green-400' : 'bg-text-3'}`} />
<span className="text-text-2">
{' '}
{settings?.defaultRole ? (
<span className="text-green-400 font-semibold"></span>
) : (
<span className="text-text-3 font-semibold"></span>
)}
</span>
</div>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${oauthSettings?.autoApproveDomains ? 'bg-blue-400' : 'bg-text-3'}`} />
<span className="text-text-2">
Google OAuth {' '}
{oauthSettings?.autoApproveDomains ? (
<span className="text-blue-400 font-semibold font-mono">{oauthSettings.autoApproveDomains}</span>
) : (
<span className="text-text-3 font-semibold"></span>
)}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
// ─── AdminView ────────────────────────────────────────────
export function AdminView() {
const { activeSubTab } = useSubMenu('admin')
return (
<div className="flex flex-1 overflow-hidden bg-bg-0">
<div className="flex-1 flex flex-col overflow-hidden">
{activeSubTab === 'users' && <UsersPanel />}
{activeSubTab === 'permissions' && <PermissionsPanel />}
{activeSubTab === 'menus' && <MenusPanel />}
{activeSubTab === 'settings' && <SettingsPanel />}
</div>
</div>
)
}