Files
core2026/app/gateway-frontend/src/views/AdminView.vue
T
Hide_D 10cd1ed6d4 feat: 관리자 계정 식별자와 카카오 영구 교체 지원
관리자 승인과 사용자 OAuth 증명을 분리하고 기존 Kakao stable ID를 영구 폐기한다. 로그인 ID와 닉네임 변경은 현재 장수에 revision 기반으로 투영하며 과거 기록은 당시 이름으로 보존한다.
2026-08-24 23:25:49 +00:00

2955 lines
146 KiB
Vue

<script setup lang="ts">
import {
formatServerDateTime,
serverDateTimeInputToIso,
toServerDateTimeInputValue,
} from '@sammo-ts/common/time/ServerDateTime';
import { gatewayProfileCapabilities, type GatewayProfileStatus } from '@sammo-ts/common/gateway/profileStatus';
import { computed, onMounted, ref, watch } from 'vue';
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
import { useToast } from '../composables/useToast';
import {
normalizeProfileResetDefaults,
PROFILE_TURN_TERM_MINUTES,
RESET_AUTORUN_LABELS,
RESET_OPTION_COPY,
type ProfileResetDefaults,
type ResetAutorunOption,
} from '../utils/resetDefaults';
import { trpc } from '../utils/trpc';
type AdminSection = 'users' | 'servers' | 'system' | 'audit';
const props = defineProps<{
section: AdminSection;
profileName?: string;
}>();
const pageMeta: Record<AdminSection, { title: string; description: string; eyebrow: string }> = {
users: {
title: '사용자 관리',
description: '계정 조회와 생성, 인증 유예, 권한, 제재, 복구 및 탈퇴 예약을 한 사용자 단위로 관리합니다.',
eyebrow: 'Accounts and access',
},
servers: {
title: '서버 관리',
description: '프로필별 공개 정보와 계정 정책, 게임 실행 상태 및 운영 동작을 관리합니다.',
eyebrow: 'Profile operations',
},
system: {
title: '공지 · 접속',
description: 'Gateway 공통 공지와 관리자 세션 연결 상태를 관리합니다.',
eyebrow: 'Gateway settings',
},
audit: {
title: '감사 로그',
description: '관리자 조치의 실행 결과, 대상과 사유를 시간순으로 확인합니다.',
eyebrow: 'Audit trail',
},
};
const currentPage = computed(() => pageMeta[props.section]);
type AdminUserSanctions = {
bannedUntil?: string;
mutedUntil?: string;
suspendedUntil?: string;
warningCount?: number;
flags?: string[];
notes?: string;
serverRestrictions?: Record<
string,
{
blockedFeatures?: string[];
until?: string;
reason?: string;
notes?: string;
}
>;
};
type AdminSanctionsPatch = {
bannedUntil?: string | null;
mutedUntil?: string | null;
suspendedUntil?: string | null;
warningCount?: number | null;
flags?: string[] | null;
notes?: string | null;
serverRestrictions?: Record<
string,
{
blockedFeatures?: string[];
until?: string | null;
reason?: string | null;
notes?: string | null;
} | null
> | null;
};
type AdminUser = {
id: string;
username: string;
displayName: string;
roles: string[];
sanctions: AdminUserSanctions;
oauthType: string;
oauthId?: string;
email?: string;
kakaoVerifiedAt?: string;
kakaoGraceStartedAt: string;
kakaoGraceUntil?: string;
identityRevision?: string;
kakaoReplacementApprovedUntil?: string;
profileIconResetAt?: string;
deleteAfter?: string;
createdAt: string;
};
type AdminUserListItem = {
id: string;
username: string;
displayName: string;
email?: string;
oauthType: 'NONE' | 'KAKAO';
roles: string[];
hasActiveSanction: boolean;
deleteAfter?: string;
createdAt: string;
};
type UserWorkspaceSection = 'account' | 'access' | 'restrictions' | 'lifecycle';
type AdminCapability = {
permission: string;
label: string;
description: string;
risk: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
scope: 'GLOBAL' | 'PROFILE';
scopes?: string[];
};
type AdminAuditEvent = {
id: string;
correlationId: string;
actorUsername: string;
targetType?: string;
targetId?: string;
profileName?: string;
profileDisplayName?: string;
action: string;
outcome: 'STARTED' | 'SUCCEEDED' | 'FAILED';
reason?: string;
summary: Record<string, unknown>;
errorMessage?: string;
createdAt: string;
};
type KakaoGracePolicy = {
profileName: string;
profile: string;
instanceKey: string;
displayName: string;
requiresKakaoVerification: boolean;
kakaoVerified: boolean;
accessAllowed: boolean;
canCreateGeneral: boolean;
graceEndsAt: string | null;
generalCreationGraceDays: number;
accessGraceDays: number;
specialAccess: {
kind: 'OPERATOR' | SpecialAccountAccessGrant['kind'];
grantId: string | null;
expiresAt: string | null;
allowsGeneralCreation: boolean;
} | null;
};
type SpecialAccountAccessGrant = {
id: string;
userId: string;
kind: 'TESTER' | 'RECOVERY' | 'OTHER';
profiles: string[];
allowsGeneralCreation: boolean;
expiresAt?: string;
reason: string;
grantedByUserId: string;
revokedAt?: string;
revokedReason?: string;
createdAt: string;
};
type AdminPublicUser = {
id: string;
username: string;
displayName: string;
roles: string[];
createdAt: string;
};
type AdminProfile = {
profileName: string;
profile: string;
instanceKey: string;
displayName?: string;
currentScenario: string | null;
/** @deprecated Rollback-compatible mirror of currentScenario. */
scenario: string;
status: GatewayProfileStatus;
apiPort: number;
runtime: {
apiRunning: boolean;
daemonRunning: boolean;
auctionRunning: boolean;
battleSimRunning: boolean;
tournamentRunning: boolean;
};
buildCommitSha?: string;
activeOperation?: {
id: string;
status: 'QUEUED' | 'RUNNING';
} | null;
meta: Record<string, unknown>;
runtimeSettings: ProfileRuntimeSettings | null;
runtimeActions: Array<{
id: string;
action: string;
durationMinutes: number | null;
status: 'REQUESTED' | 'PARTIAL' | 'APPLIED' | 'FAILED' | 'IGNORED';
detail: string | null;
handler: string | null;
handledAt: string | null;
createdAt: string;
payload?: Record<string, unknown>;
}>;
};
type ProfileRuntimeSettings = Pick<ProfileResetDefaults, 'turnTermMinutes' | 'blockGeneralCreate' | 'autorunUser'> & {
isUnited: number;
};
type ProfileRuntimeSettingsUpdate = Omit<ProfileRuntimeSettings, 'isUnited'>;
type AdminAction =
| 'RESUME'
| 'PAUSE'
| 'STOP'
| 'CLOSE_COMPLETED'
| 'ACCELERATE'
| 'DELAY'
| 'UPDATE_RUNTIME_SETTINGS'
| 'RESET_NOW'
| 'RESET_SCHEDULED'
| 'SHUTDOWN';
type AdminClient = {
capabilities: {
list: { query: () => Promise<AdminCapability[]> };
};
audit: {
list: { query: (input?: { limit?: number }) => Promise<AdminAuditEvent[]> };
};
system: {
getNotice: {
query: () => Promise<{ notice: string }>;
};
setNotice: {
mutate: (input: { notice: string }) => Promise<{ notice: string }>;
};
};
users: {
list: {
query: (input?: { query?: string; limit?: number; cursor?: string }) => Promise<{
users: AdminUserListItem[];
total: number;
nextCursor?: string;
}>;
};
getLocalAccountStatus: {
query: () => Promise<{ enabled: boolean }>;
};
createLocal: {
mutate: (input: {
username: string;
password: string;
displayName?: string;
}) => Promise<{ user: AdminPublicUser }>;
};
lookup: {
query: (input: { id?: string; username?: string; email?: string }) => Promise<AdminUser | null>;
};
getKakaoGracePolicies: {
query: (input: { userId: string }) => Promise<{
kakaoVerified: boolean;
kakaoGraceStartedAt: string;
kakaoGraceUntil: string | null;
specialAccessGrants: SpecialAccountAccessGrant[];
profiles: KakaoGracePolicy[];
}>;
};
updateKakaoGrace: {
mutate: (input: { userId: string; until: string | null; reason: string }) => Promise<{
kakaoGraceUntil: string | null;
}>;
};
setKakaoReplacementApproval: {
mutate: (input: { userId: string; until: string | null; reason: string }) => Promise<{
kakaoReplacementApprovedUntil: string | null;
}>;
};
updateIdentity: {
mutate: (input: {
userId: string;
username: string;
displayName: string;
reason: string;
}) => Promise<{ username: string; displayName: string; identityRevision?: string }>;
};
grantSpecialAccess: {
mutate: (input: {
userId: string;
kind: SpecialAccountAccessGrant['kind'];
profiles: string[];
allowsGeneralCreation: boolean;
expiresAt: string | null;
reason: string;
}) => Promise<SpecialAccountAccessGrant>;
};
revokeSpecialAccess: {
mutate: (input: { userId: string; grantId: string; reason: string }) => Promise<SpecialAccountAccessGrant>;
};
listHistory: {
query: (input: { userId: string; limit?: number }) => Promise<AdminAuditEvent[]>;
};
resetPassword: {
mutate: (input: { userId: string; newPassword?: string; reason: string }) => Promise<{ password: string }>;
};
updateRoles: {
mutate: (input: {
userId: string;
roles: string[];
mode?: 'set' | 'grant' | 'revoke';
reason: string;
}) => Promise<{ roles: string[] }>;
};
updateSanctions: {
mutate: (input: {
userId: string;
patch: AdminSanctionsPatch;
reason: string;
}) => Promise<{ sanctions: AdminUserSanctions }>;
};
setServerRestriction: {
mutate: (input: {
userId: string;
profile: string;
restriction: {
blockedFeatures?: string[];
until?: string | null;
reason?: string | null;
notes?: string | null;
} | null;
reason: string;
}) => Promise<{ sanctions: AdminUserSanctions }>;
};
resetProfileIcon: {
mutate: (input: { userId: string; reason: string }) => Promise<{
profileIconResetAt: string;
flushPublished: boolean;
}>;
};
scheduleDeletion: {
mutate: (input: { userId: string; retentionDays: number; reason: string }) => Promise<{
ok: boolean;
deleteAfter: string;
}>;
};
};
profiles: {
list: {
query: () => Promise<AdminProfile[]>;
};
updateMeta: {
mutate: (input: {
profileName: string;
patch: {
korName?: string | null;
color?: string | null;
inGameNotice?: string | null;
profileImageUrl?: string | null;
nextSeasonIdx?: number | null;
firstGameIdx?: number | null;
localAccountAccessGraceDays?: number | null;
localAccountGeneralCreationGraceDays?: number | null;
resetDefaults?: ProfileResetDefaults | null;
};
reason: string;
}) => Promise<AdminProfile | null>;
};
requestAction: {
mutate: (input: {
profileName: string;
action: AdminAction;
durationMinutes?: number;
runtimeSettings?: ProfileRuntimeSettingsUpdate;
scheduledAt?: string;
reason?: string;
}) => Promise<{ ok: boolean; action?: AdminProfile['runtimeActions'][number] }>;
};
};
};
const adminClient = trpc.admin as unknown as AdminClient;
const sessionToken = ref('');
const sessionTokenStatus = ref('');
if (typeof window !== 'undefined') {
sessionToken.value = window.localStorage.getItem('sammo-session-token') ?? '';
}
const saveSessionToken = () => {
const value = sessionToken.value.trim();
if (typeof window !== 'undefined') {
if (value) {
window.localStorage.setItem('sammo-session-token', value);
} else {
window.localStorage.removeItem('sammo-session-token');
}
}
sessionTokenStatus.value = value ? '저장됨' : '삭제됨';
};
const noticeDraft = ref('');
const noticeStatus = ref('');
const noticeLoading = ref(false);
const profiles = ref<AdminProfile[]>([]);
const profilesLoading = ref(false);
const profileEdits = ref<
Record<
string,
{
korName: string;
color: string;
inGameNotice: string;
profileImageUrl: string;
nextSeasonIdx: string | number;
firstGameIdx: string | number;
localAccountAccessGraceDays: string;
localAccountGeneralCreationGraceDays: string;
resetDefaults: ProfileResetDefaults;
resetAutorunEnabled: boolean;
reason: string;
}
>
>({});
const profileActions = ref<
Record<
string,
{
durationMinutes: string;
scheduledAt: string;
reason: string;
turnTermMinutes: number;
blockGeneralCreate: 0 | 1 | 2;
autorunEnabled: boolean;
autorunUserMinutes: string;
autorunOptions: ResetAutorunOption[];
}
>
>({});
const profileActionStatus = ref<Record<string, string>>({});
const profileActionSubmitting = ref<Record<string, boolean>>({});
const visibleProfiles = computed(() =>
props.profileName ? profiles.value.filter((profile) => profile.profileName === props.profileName) : profiles.value
);
const adminProfileDisplayName = (profile: AdminProfile): string => {
if (profile.displayName?.trim()) return profile.displayName.trim();
const configuredName = profile.meta.korName;
const baseName =
typeof configuredName === 'string' && configuredName.trim() ? configuredName.trim() : profile.profile;
return profile.instanceKey === 'default' ? baseName : `${baseName} [${profile.instanceKey}]`;
};
const runtimeActionPending = (profile: AdminProfile): boolean => {
return profile.runtimeActions.some((action) => action.status === 'REQUESTED' || action.status === 'PARTIAL');
};
const profileLifecycleText = (profile: AdminProfile): string => {
if (profile.currentScenario === null) return 'DB 초기화 전 · 게임 접근 불가';
if (profile.status === 'STOPPED') return '서버 프로세스 중지 · 게임 접근 불가 · 운영자 서버 재개 가능';
if (profile.status === 'CANCELLED') return '취소 게임 · 접근 및 재개 불가 · 새 시나리오 초기화 필요';
if (profile.status === 'DISABLED') return '비활성 · 게임 접근 불가';
if (Number(profile.runtimeSettings?.isUnited ?? 0) !== 0)
return '천하통일 완료 · 종료 기수 조회 가능 · 외부 목록 정리 대기';
if (profile.status === 'PAUSED') return '턴 일시정지 · 게임 조회와 예약턴 입력 가능 · 운영자 재개 가능';
if (profile.status === 'RUNNING') return '서버 운영 및 턴 진행 중';
if (profile.status === 'PREOPEN') return '서버 접근 가능 · 개장 전 턴 정지';
if (profile.status === 'COMPLETED') return '종료 기수 조회 가능 · 턴 정지';
return '준비 중 · 게임 접근 불가';
};
const canResumeProfile = (profile: AdminProfile): boolean =>
profile.currentScenario !== null && gatewayProfileCapabilities(profile.status).operatorResumable;
const canPauseProfile = (profile: AdminProfile): boolean => profile.status === 'RUNNING';
const canStopProfile = (profile: AdminProfile): boolean => gatewayProfileCapabilities(profile.status).runtimeExpected;
const canCloseCompletedProfile = (profile: AdminProfile): boolean =>
gatewayProfileCapabilities(profile.status).runtimeExpected &&
(profile.status === 'COMPLETED' || Number(profile.runtimeSettings?.isUnited ?? 0) !== 0);
const validDuration = (profileName: string): boolean => {
const value = Number(profileActions.value[profileName]?.durationMinutes);
return Number.isInteger(value) && value >= 1 && value <= 1440;
};
const validRuntimeSettings = (profileName: string): boolean => {
const value = profileActions.value[profileName];
if (!value || !PROFILE_TURN_TERM_MINUTES.some((minutes) => minutes === value.turnTermMinutes)) return false;
if (!value.autorunEnabled) return true;
const limitMinutes = Number(value.autorunUserMinutes);
return (
Number.isInteger(limitMinutes) && limitMinutes >= 1 && limitMinutes <= 43200 && value.autorunOptions.length > 0
);
};
const runtimeActionStatusClass = (status: AdminProfile['runtimeActions'][number]['status']): string => {
if (status === 'APPLIED') return 'text-emerald-400';
if (status === 'FAILED') return 'text-red-400';
if (status === 'IGNORED') return 'text-orange-400';
if (status === 'PARTIAL') return 'text-amber-400';
return 'text-zinc-400';
};
const isRuntimeActionTerminal = (status: AdminProfile['runtimeActions'][number]['status']): boolean =>
status === 'APPLIED' || status === 'FAILED' || status === 'IGNORED';
const formatRuntimeActionTime = (value: string | null): string => formatServerDateTime(value);
const userLookupMode = ref<'username' | 'id' | 'email'>('username');
const userLookupValue = ref('');
const userLoading = ref(false);
const userError = ref('');
const userResult = ref<AdminUser | null>(null);
const userDirectoryQuery = ref('');
const userDirectory = ref<AdminUserListItem[]>([]);
const userDirectoryTotal = ref(0);
const userDirectoryNextCursor = ref<string>();
const userDirectoryLoading = ref(false);
const userDirectoryError = ref('');
const userWorkspaceSection = ref<UserWorkspaceSection>('account');
const userWorkspaceSections: Array<{ id: UserWorkspaceSection; label: string; description: string }> = [
{ id: 'account', label: '계정', description: '기본 정보와 로컬 계정 생성' },
{ id: 'access', label: '접근 · 권한', description: '비밀번호, 운영 권한, Kakao 접근' },
{ id: 'restrictions', label: '보안 · 제재', description: '차단, 서버 제한, 아이콘 초기화' },
{ id: 'lifecycle', label: '탈퇴 · 이력', description: '탈퇴 예약과 관리자 조치 이력' },
];
const localAccountEnabled = ref(false);
const localAccountStatus = ref('');
const localAccountResult = ref('');
const localAccountLoading = ref(false);
const localAccountForm = ref({
username: '',
password: '',
displayName: '',
});
const passwordInput = ref('');
const passwordResult = ref('');
const passwordStatus = ref('');
const identityUsername = ref('');
const identityDisplayName = ref('');
const identityStatus = ref('');
const kakaoReplacementUntil = ref('');
const kakaoReplacementStatus = ref('');
const rolesInput = ref('');
const rolesMode = ref<'set' | 'grant' | 'revoke'>('grant');
const rolesStatus = ref('');
const capabilities = ref<AdminCapability[]>([]);
const hasCapability = (permission: string, profileName?: string): boolean =>
capabilities.value.some((entry) => {
if (entry.permission !== permission) return false;
if (!profileName || entry.scope === 'GLOBAL') return true;
return !entry.scopes?.length || entry.scopes.includes('*') || entry.scopes.includes(profileName);
});
const selectedCapability = ref('');
const capabilityProfile = ref('');
const userActionReason = ref('');
const banUntil = ref('');
const banReason = ref('');
const banStatus = ref('');
const profileIconStatus = ref('');
const restrictionProfile = ref('');
const restrictionFeatures = ref('');
const restrictionUntil = ref('');
const restrictionReason = ref('');
const restrictionNotes = ref('');
const restrictionStatus = ref('');
const forceDeleteStatus = ref('');
const deletionRetentionDays = ref(30);
const kakaoGraceUntil = ref('');
const kakaoGraceStatus = ref('');
const kakaoPolicies = ref<KakaoGracePolicy[]>([]);
const specialAccessGrants = ref<SpecialAccountAccessGrant[]>([]);
const specialAccessKind = ref<SpecialAccountAccessGrant['kind']>('RECOVERY');
const specialAccessProfiles = ref<string[]>([]);
const specialAccessAllowsGeneralCreation = ref(true);
const specialAccessExpiresAt = ref('');
const specialAccessStatus = ref('');
const userHistory = ref<AdminAuditEvent[]>([]);
const globalAuditHistory = ref<AdminAuditEvent[]>([]);
const globalAuditStatus = ref('');
const profileScopeLabel = (scope: string): string => {
const exact = kakaoPolicies.value.find((policy) => policy.profileName === scope);
if (exact) return exact.displayName;
const base = kakaoPolicies.value.filter((policy) => policy.profile === scope);
if (!base.length) return '삭제되었거나 접근할 수 없는 서버';
const baseName = base[0]!.displayName.replace(/ \[[^\]]+\]$/, '');
return base.length > 1 ? `${baseName} 전체 인스턴스` : baseName;
};
const specialAccessScopeOptions = computed(() => {
const grouped = new Map<string, KakaoGracePolicy[]>();
for (const policy of kakaoPolicies.value) {
const bucket = grouped.get(policy.profile) ?? [];
bucket.push(policy);
grouped.set(policy.profile, bucket);
}
return [...grouped.entries()].flatMap(([profile, entries]) => {
if (entries.length === 1) return [{ value: profile, label: entries[0]!.displayName }];
const baseName = entries[0]!.displayName.replace(/ \[[^\]]+\]$/, '');
return [
{ value: profile, label: `${baseName} 전체 인스턴스` },
...entries.map((entry) => ({ value: entry.profileName, label: entry.displayName })),
];
});
});
const auditTargetLabel = (event: AdminAuditEvent): string => {
if (event.profileDisplayName) return event.profileDisplayName;
if (event.targetType === 'PROFILE' || event.profileName) return '삭제되었거나 접근할 수 없는 서버';
return event.targetId ?? '';
};
const { feedback: showFeedbackToast } = useToast();
const actionFeedback = [
noticeStatus,
userError,
kakaoGraceStatus,
identityStatus,
kakaoReplacementStatus,
specialAccessStatus,
passwordStatus,
rolesStatus,
banStatus,
profileIconStatus,
restrictionStatus,
forceDeleteStatus,
];
watch(
actionFeedback,
(current, previous) => {
current.forEach((message, index) => {
if (message && message !== previous[index]) showFeedbackToast(message);
});
},
{ flush: 'sync' }
);
const setLocalAccountFeedback = (message: string): void => {
localAccountStatus.value = message;
showFeedbackToast(message);
};
watch(
profileActionStatus,
(current, previous) => {
Object.entries(current).forEach(([profileName, message]) => {
if (message && message !== previous[profileName]) showFeedbackToast(message);
});
},
{ flush: 'sync' }
);
const hasUser = computed(() => Boolean(userResult.value));
const loadLocalAccountStatus = async () => {
localAccountStatus.value = '';
try {
const result = await adminClient.users.getLocalAccountStatus.query();
localAccountEnabled.value = result.enabled;
localAccountStatus.value = result.enabled ? '' : 'ENV 설정이 비활성화 상태입니다.';
} catch (error) {
localAccountEnabled.value = false;
localAccountStatus.value = '로컬 계정 생성 설정 확인 실패';
}
};
const loadNotice = async () => {
noticeLoading.value = true;
try {
const result = await adminClient.system.getNotice.query();
noticeDraft.value = result.notice;
} catch (error) {
noticeStatus.value = '공지 불러오기 실패';
} finally {
noticeLoading.value = false;
}
};
const saveNotice = async () => {
noticeLoading.value = true;
noticeStatus.value = '';
try {
const result = await adminClient.system.setNotice.mutate({
notice: noticeDraft.value,
});
noticeDraft.value = result.notice;
noticeStatus.value = '저장 완료';
} catch (error) {
noticeStatus.value = '저장 실패';
} finally {
noticeLoading.value = false;
}
};
const ensureProfileBuffers = (profile: AdminProfile) => {
if (!profileEdits.value[profile.profileName]) {
const meta = (profile.meta ?? {}) as Record<string, unknown>;
const resetDefaults = normalizeProfileResetDefaults(meta.resetDefaults);
profileEdits.value[profile.profileName] = {
korName: String(meta.korName ?? profile.profile),
color: String(meta.color ?? '#ffffff'),
inGameNotice: String(meta.inGameNotice ?? ''),
profileImageUrl: String(meta.profileImageUrl ?? ''),
nextSeasonIdx:
typeof meta.nextSeasonIdx === 'number' && Number.isFinite(meta.nextSeasonIdx)
? String(Math.floor(meta.nextSeasonIdx))
: '',
firstGameIdx:
typeof meta.firstGameIdx === 'number' && Number.isFinite(meta.firstGameIdx)
? String(Math.floor(meta.firstGameIdx))
: '1',
localAccountAccessGraceDays:
typeof meta.localAccountAccessGraceDays === 'number'
? String(Math.floor(meta.localAccountAccessGraceDays))
: '',
localAccountGeneralCreationGraceDays:
typeof meta.localAccountGeneralCreationGraceDays === 'number'
? String(Math.floor(meta.localAccountGeneralCreationGraceDays))
: '',
resetDefaults,
resetAutorunEnabled: resetDefaults.autorunUser !== null,
reason: '',
};
}
if (!profileActions.value[profile.profileName]) {
const settings = profile.runtimeSettings ?? normalizeProfileResetDefaults(profile.meta?.resetDefaults);
profileActions.value[profile.profileName] = {
durationMinutes: '',
scheduledAt: '',
reason: '',
turnTermMinutes: settings.turnTermMinutes,
blockGeneralCreate: settings.blockGeneralCreate,
autorunEnabled: settings.autorunUser !== null,
autorunUserMinutes: String(settings.autorunUser?.limitMinutes ?? 1440),
autorunOptions: settings.autorunUser?.options.slice() ?? RESET_AUTORUN_LABELS.map(({ value }) => value),
};
}
};
const syncRuntimeSettingsBuffer = (profile: AdminProfile): void => {
const settings = profile.runtimeSettings;
const buffer = profileActions.value[profile.profileName];
if (!settings || !buffer) return;
buffer.turnTermMinutes = settings.turnTermMinutes;
buffer.blockGeneralCreate = settings.blockGeneralCreate;
buffer.autorunEnabled = settings.autorunUser !== null;
buffer.autorunUserMinutes = String(settings.autorunUser?.limitMinutes ?? 1440);
buffer.autorunOptions = settings.autorunUser?.options.slice() ?? RESET_AUTORUN_LABELS.map(({ value }) => value);
};
const toLocalInputValue = (value: string): string => toServerDateTimeInputValue(value);
const loadProfiles = async () => {
profilesLoading.value = true;
try {
const result = await adminClient.profiles.list.query();
result.forEach((profile) => {
ensureProfileBuffers(profile);
const latest = profile.runtimeActions[0];
if (latest && isRuntimeActionTerminal(latest.status)) {
profileActionStatus.value = {
...profileActionStatus.value,
[profile.profileName]: '',
};
}
});
profiles.value = result;
} catch (error) {
profileActionStatus.value = {
...profileActionStatus.value,
global: '프로필 목록을 불러오지 못했습니다.',
};
} finally {
profilesLoading.value = false;
}
};
const refreshRuntimeActionUntilTerminal = async (profileName: string, actionId: string): Promise<void> => {
for (let attempt = 0; attempt < 20; attempt += 1) {
const current = profiles.value
.find((profile) => profile.profileName === profileName)
?.runtimeActions.find((action) => action.id === actionId);
if (current && isRuntimeActionTerminal(current.status)) {
const profile = profiles.value.find((item) => item.profileName === profileName);
if (profile && current.status === 'APPLIED') syncRuntimeSettingsBuffer(profile);
profileActionStatus.value = {
...profileActionStatus.value,
[profileName]: '',
};
return;
}
await new Promise((resolve) => setTimeout(resolve, 1_000));
try {
const result = await adminClient.profiles.list.query();
result.forEach((profile) => {
ensureProfileBuffers(profile);
});
profiles.value = result;
const refreshedAction = result
.find((profile) => profile.profileName === profileName)
?.runtimeActions.find((action) => action.id === actionId);
if (refreshedAction?.status === 'APPLIED') {
const profile = result.find((item) => item.profileName === profileName);
if (profile) syncRuntimeSettingsBuffer(profile);
}
} catch {
// 일시적인 조회 실패는 다음 bounded poll에서 다시 확인합니다.
}
}
};
const updateProfileMeta = async (profileName: string) => {
const edit = profileEdits.value[profileName];
if (!edit) {
return;
}
// Vue casts non-empty values from type="number" inputs to numbers even when
// the buffer was initialized with a string. Normalize both runtime shapes so
// event season 0 reaches the metadata mutation instead of throwing on trim().
const nextSeasonRaw = String(edit.nextSeasonIdx).trim();
const nextSeasonIdx = nextSeasonRaw === '' ? null : Number(nextSeasonRaw);
if (nextSeasonIdx !== null && (!Number.isFinite(nextSeasonIdx) || nextSeasonIdx < 0)) {
profileActionStatus.value = {
...profileActionStatus.value,
[profileName]: '다음 시즌 번호는 0 이상 숫자여야 합니다.',
};
return;
}
const firstGameIdxRaw = String(edit.firstGameIdx).trim();
const firstGameIdx = firstGameIdxRaw === '' ? null : Number(firstGameIdxRaw);
if (firstGameIdx !== null && (!Number.isInteger(firstGameIdx) || firstGameIdx < 0)) {
profileActionStatus.value = {
...profileActionStatus.value,
[profileName]: '첫 기수 번호는 0 이상 정수여야 합니다.',
};
return;
}
const readGraceDays = (value: string): number | null => {
if (!value.trim()) return null;
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 365 ? parsed : Number.NaN;
};
const accessGraceDays = readGraceDays(edit.localAccountAccessGraceDays);
const creationGraceDays = readGraceDays(edit.localAccountGeneralCreationGraceDays);
if (Number.isNaN(accessGraceDays) || Number.isNaN(creationGraceDays)) {
profileActionStatus.value = {
...profileActionStatus.value,
[profileName]: 'Kakao 유예일은 0~365 사이 정수여야 합니다.',
};
return;
}
if (edit.reason.trim().length < 3) {
profileActionStatus.value = { ...profileActionStatus.value, [profileName]: '변경 사유를 입력하세요.' };
return;
}
if (
edit.resetAutorunEnabled &&
(!edit.resetDefaults.autorunUser ||
edit.resetDefaults.autorunUser.limitMinutes <= 0 ||
edit.resetDefaults.autorunUser.limitMinutes > 43200 ||
edit.resetDefaults.autorunUser.options.length === 0)
) {
profileActionStatus.value = {
...profileActionStatus.value,
[profileName]: '자율행동은 유효 시간과 한 개 이상의 행동을 선택해야 합니다.',
};
return;
}
const patch = {
korName: edit.korName.trim() || null,
color: edit.color.trim() || null,
inGameNotice: edit.inGameNotice.trim() || null,
profileImageUrl: edit.profileImageUrl.trim() || null,
nextSeasonIdx: nextSeasonIdx === null ? null : Math.floor(nextSeasonIdx),
firstGameIdx,
localAccountAccessGraceDays: accessGraceDays,
localAccountGeneralCreationGraceDays: creationGraceDays,
resetDefaults: {
...edit.resetDefaults,
autorunUser: edit.resetAutorunEnabled ? edit.resetDefaults.autorunUser : null,
},
};
try {
const updated = await adminClient.profiles.updateMeta.mutate({
profileName,
patch,
reason: edit.reason.trim(),
});
profileActionStatus.value = {
...profileActionStatus.value,
[profileName]: updated ? '메타 저장 완료' : '메타 저장 실패',
};
if (updated) {
profiles.value = profiles.value.map((item) =>
item.profileName === profileName ? { ...item, ...updated } : item
);
}
} catch (error) {
const detail = error instanceof Error ? error.message : '';
profileActionStatus.value = {
...profileActionStatus.value,
[profileName]: detail ? `메타 저장 실패: ${detail}` : '메타 저장 실패',
};
}
};
const ensureResetAutorun = (profileName: string) => {
const edit = profileEdits.value[profileName];
if (!edit?.resetAutorunEnabled || edit.resetDefaults.autorunUser) return;
edit.resetDefaults.autorunUser = {
limitMinutes: 1440,
options: RESET_AUTORUN_LABELS.map(({ value }) => value),
};
};
const requestProfileAction = async (profileName: string, action: AdminAction) => {
if (profileActionSubmitting.value[profileName]) {
return;
}
profileActionSubmitting.value = {
...profileActionSubmitting.value,
[profileName]: true,
};
const actionState = profileActions.value[profileName];
const timeShiftAction = action === 'ACCELERATE' || action === 'DELAY';
const durationMinutes =
timeShiftAction && actionState?.durationMinutes ? Number(actionState.durationMinutes) : undefined;
const durationValue = durationMinutes && validDuration(profileName) ? durationMinutes : undefined;
const scheduledAt =
action === 'RESET_SCHEDULED' && actionState?.scheduledAt
? serverDateTimeInputToIso(actionState.scheduledAt)
: undefined;
const reason = actionState?.reason.trim() || undefined;
const runtimeSettings: ProfileRuntimeSettingsUpdate | undefined =
action === 'UPDATE_RUNTIME_SETTINGS' && actionState
? {
turnTermMinutes: actionState.turnTermMinutes,
blockGeneralCreate: actionState.blockGeneralCreate,
autorunUser: actionState.autorunEnabled
? {
limitMinutes: Number(actionState.autorunUserMinutes),
options: actionState.autorunOptions,
}
: null,
}
: undefined;
if (action === 'UPDATE_RUNTIME_SETTINGS' && (!validRuntimeSettings(profileName) || !reason || reason.length < 3)) {
profileActionStatus.value = {
...profileActionStatus.value,
[profileName]:
!reason || reason.length < 3
? '현재 기수 설정 변경 사유를 입력하세요.'
: '턴 시간과 자율행동 값을 확인하세요.',
};
profileActionSubmitting.value = { ...profileActionSubmitting.value, [profileName]: false };
return;
}
let runtimeActionId: string | undefined;
try {
const result = await adminClient.profiles.requestAction.mutate({
profileName,
action,
durationMinutes: durationValue,
runtimeSettings,
scheduledAt,
reason,
});
profileActionStatus.value = {
...profileActionStatus.value,
[profileName]:
result.action && (action === 'ACCELERATE' || action === 'DELAY')
? `접수됨 · ${result.action.status} · ${action} ${result.action.durationMinutes ?? ''}분`
: result.action && action === 'UPDATE_RUNTIME_SETTINGS'
? `접수됨 · ${result.action.status} · 현재 기수 설정 변경`
: `요청 접수: ${action}`,
};
if (result.action) {
runtimeActionId = result.action.id;
await loadProfiles();
}
} catch (error) {
profileActionStatus.value = {
...profileActionStatus.value,
[profileName]: `요청 실패: ${action}`,
};
} finally {
profileActionSubmitting.value = {
...profileActionSubmitting.value,
[profileName]: false,
};
}
if (runtimeActionId) {
void refreshRuntimeActionUntilTerminal(profileName, runtimeActionId);
}
};
const lookupUser = async () => {
userLoading.value = true;
userError.value = '';
passwordStatus.value = '';
rolesStatus.value = '';
banStatus.value = '';
restrictionStatus.value = '';
profileIconStatus.value = '';
forceDeleteStatus.value = '';
try {
const payload =
userLookupMode.value === 'id'
? { id: userLookupValue.value.trim() }
: userLookupMode.value === 'email'
? { email: userLookupValue.value.trim() }
: { username: userLookupValue.value.trim() };
const result = await adminClient.users.lookup.query(payload);
if (!result) {
userResult.value = null;
userError.value = '사용자를 찾을 수 없습니다.';
return;
}
userResult.value = result;
identityUsername.value = result.username;
identityDisplayName.value = result.displayName;
kakaoReplacementUntil.value = result.kakaoReplacementApprovedUntil
? toLocalInputValue(result.kakaoReplacementApprovedUntil)
: toLocalInputValue(new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString());
const [grace, history] = await Promise.all([
adminClient.users.getKakaoGracePolicies.query({ userId: result.id }),
adminClient.users.listHistory.query({ userId: result.id, limit: 50 }),
]);
kakaoPolicies.value = grace.profiles;
specialAccessGrants.value = grace.specialAccessGrants;
kakaoGraceUntil.value = grace.kakaoGraceUntil ? toLocalInputValue(grace.kakaoGraceUntil) : '';
userHistory.value = history;
} catch (error) {
userError.value = '조회 실패';
} finally {
userLoading.value = false;
}
};
const loadUserDirectory = async (append = false) => {
userDirectoryLoading.value = true;
userDirectoryError.value = '';
try {
const result = await adminClient.users.list.query({
query: userDirectoryQuery.value.trim() || undefined,
limit: 30,
cursor: append ? userDirectoryNextCursor.value : undefined,
});
userDirectory.value = append ? [...userDirectory.value, ...result.users] : result.users;
userDirectoryTotal.value = result.total;
userDirectoryNextCursor.value = result.nextCursor;
} catch {
userDirectoryError.value = '계정 목록을 불러오지 못했습니다.';
} finally {
userDirectoryLoading.value = false;
}
};
const selectDirectoryUser = async (user: AdminUserListItem) => {
userLookupMode.value = 'id';
userLookupValue.value = user.id;
await lookupUser();
};
const requireUserActionReason = (): string | null => {
const reason = userActionReason.value.trim();
if (reason.length < 3) {
userError.value = '민감한 관리자 조치에는 3자 이상의 사유가 필요합니다.';
return null;
}
return reason;
};
const loadGlobalAudit = async () => {
if (!capabilities.value.some((entry) => entry.permission === 'admin.audit.read')) return;
try {
globalAuditHistory.value = await adminClient.audit.list.query({ limit: 100 });
globalAuditStatus.value = '';
} catch {
globalAuditStatus.value = '감사 원장을 불러오지 못했습니다.';
}
};
const refreshUserHistory = async () => {
if (!userResult.value) return;
userHistory.value = await adminClient.users.listHistory.query({ userId: userResult.value.id, limit: 50 });
await loadGlobalAudit();
};
const loadCapabilities = async () => {
try {
capabilities.value = await adminClient.capabilities.list.query();
selectedCapability.value = capabilities.value[0]?.permission ?? '';
if (props.section === 'users' || props.section === 'audit') await loadGlobalAudit();
} catch {
capabilities.value = [];
}
};
const applyCapabilitySelection = () => {
const capability = capabilities.value.find((entry) => entry.permission === selectedCapability.value);
if (!capability) return;
if (capability.scope === 'PROFILE' && !capabilityProfile.value.trim()) {
rolesStatus.value = '대상 서버를 선택하세요.';
return;
}
rolesInput.value =
capability.scope === 'PROFILE'
? `${capability.permission}:${capabilityProfile.value.trim()}`
: capability.permission;
};
const updateKakaoGrace = async (clear = false) => {
if (!userResult.value) return;
const reason = requireUserActionReason();
if (!reason) return;
try {
const result = await adminClient.users.updateKakaoGrace.mutate({
userId: userResult.value.id,
until: clear || !kakaoGraceUntil.value ? null : (serverDateTimeInputToIso(kakaoGraceUntil.value) ?? null),
reason,
});
userResult.value = {
...userResult.value,
kakaoGraceUntil: result.kakaoGraceUntil ?? undefined,
};
kakaoGraceStatus.value = result.kakaoGraceUntil ? 'OAuth 유예 연장 완료' : '개별 유예 해제 완료';
const grace = await adminClient.users.getKakaoGracePolicies.query({ userId: userResult.value.id });
kakaoPolicies.value = grace.profiles;
specialAccessGrants.value = grace.specialAccessGrants;
await Promise.all([refreshUserHistory(), loadUserDirectory()]);
} catch {
kakaoGraceStatus.value = 'OAuth 유예 변경 실패';
}
};
const updateUserIdentity = async () => {
if (!userResult.value) return;
const reason = requireUserActionReason();
if (!reason) return;
identityStatus.value = '';
try {
const result = await adminClient.users.updateIdentity.mutate({
userId: userResult.value.id,
username: identityUsername.value,
displayName: identityDisplayName.value,
reason,
});
userResult.value = { ...userResult.value, ...result };
identityUsername.value = result.username;
identityDisplayName.value = result.displayName;
identityStatus.value = 'ID와 닉네임을 변경했습니다.';
await Promise.all([refreshUserHistory(), loadUserDirectory()]);
} catch {
identityStatus.value = 'ID 또는 닉네임 변경에 실패했습니다.';
}
};
const setKakaoReplacementApproval = async (clear = false) => {
if (!userResult.value) return;
const reason = requireUserActionReason();
if (!reason) return;
const until = clear ? null : (serverDateTimeInputToIso(kakaoReplacementUntil.value) ?? null);
if (!clear && !until) {
kakaoReplacementStatus.value = '올바른 교체 승인 만료 시각을 입력하세요.';
return;
}
try {
const result = await adminClient.users.setKakaoReplacementApproval.mutate({
userId: userResult.value.id,
until,
reason,
});
userResult.value = {
...userResult.value,
kakaoReplacementApprovedUntil: result.kakaoReplacementApprovedUntil ?? undefined,
};
kakaoReplacementStatus.value = result.kakaoReplacementApprovedUntil
? '새 카카오 계정 교체를 승인했습니다.'
: '카카오 계정 교체 승인을 해제했습니다.';
await refreshUserHistory();
} catch {
kakaoReplacementStatus.value = '카카오 계정 교체 승인 변경에 실패했습니다.';
}
};
const grantSpecialAccess = async () => {
if (!userResult.value) return;
const reason = requireUserActionReason();
if (!reason) return;
specialAccessStatus.value = '';
try {
await adminClient.users.grantSpecialAccess.mutate({
userId: userResult.value.id,
kind: specialAccessKind.value,
profiles: specialAccessProfiles.value,
allowsGeneralCreation: specialAccessAllowsGeneralCreation.value,
expiresAt: specialAccessExpiresAt.value
? (serverDateTimeInputToIso(specialAccessExpiresAt.value) ?? null)
: null,
reason,
});
const policy = await adminClient.users.getKakaoGracePolicies.query({ userId: userResult.value.id });
kakaoPolicies.value = policy.profiles;
specialAccessGrants.value = policy.specialAccessGrants;
specialAccessStatus.value = '특수 접근 자격을 부여했습니다.';
await Promise.all([refreshUserHistory(), loadUserDirectory()]);
} catch {
specialAccessStatus.value = '특수 접근 자격 부여에 실패했습니다.';
}
};
const revokeSpecialAccess = async (grantId: string) => {
if (!userResult.value) return;
const reason = requireUserActionReason();
if (!reason) return;
try {
await adminClient.users.revokeSpecialAccess.mutate({ userId: userResult.value.id, grantId, reason });
const policy = await adminClient.users.getKakaoGracePolicies.query({ userId: userResult.value.id });
kakaoPolicies.value = policy.profiles;
specialAccessGrants.value = policy.specialAccessGrants;
specialAccessStatus.value = '특수 접근 자격을 해제했습니다.';
await Promise.all([refreshUserHistory(), loadUserDirectory()]);
} catch {
specialAccessStatus.value = '특수 접근 자격 해제에 실패했습니다.';
}
};
const resetUserPassword = async () => {
if (!userResult.value) {
return;
}
const reason = requireUserActionReason();
if (!reason) return;
passwordStatus.value = '';
passwordResult.value = '';
try {
const result = await adminClient.users.resetPassword.mutate({
userId: userResult.value.id,
newPassword: passwordInput.value.trim() || undefined,
reason,
});
passwordResult.value = result.password;
passwordStatus.value = '초기화 완료';
passwordInput.value = '';
await Promise.all([refreshUserHistory(), loadUserDirectory()]);
} catch (error) {
passwordStatus.value = '초기화 실패';
}
};
const updateUserRoles = async () => {
if (!userResult.value) {
return;
}
const reason = requireUserActionReason();
if (!reason) return;
const roles = rolesInput.value
.split(',')
.map((role) => role.trim())
.filter(Boolean);
if (!roles.length) {
rolesStatus.value = '역할을 입력하세요.';
return;
}
rolesStatus.value = '';
try {
const result = await adminClient.users.updateRoles.mutate({
userId: userResult.value.id,
roles,
mode: rolesMode.value,
reason,
});
userResult.value = { ...userResult.value, roles: result.roles };
rolesStatus.value = '권한 업데이트 완료';
await Promise.all([refreshUserHistory(), loadUserDirectory()]);
} catch (error) {
rolesStatus.value = '권한 업데이트 실패';
}
};
const applyBan = async () => {
if (!userResult.value) {
return;
}
const reason = requireUserActionReason();
if (!reason) return;
const until = banUntil.value ? (serverDateTimeInputToIso(banUntil.value) ?? null) : null;
const patch = {
bannedUntil: until,
notes: banReason.value.trim() || undefined,
};
try {
const result = await adminClient.users.updateSanctions.mutate({
userId: userResult.value.id,
patch,
reason,
});
userResult.value = { ...userResult.value, sanctions: result.sanctions };
banStatus.value = '차단 설정 완료';
await Promise.all([refreshUserHistory(), loadUserDirectory()]);
} catch (error) {
banStatus.value = '차단 설정 실패';
}
};
const clearBan = async () => {
if (!userResult.value) {
return;
}
const reason = requireUserActionReason();
if (!reason) return;
try {
const result = await adminClient.users.updateSanctions.mutate({
userId: userResult.value.id,
patch: { bannedUntil: null },
reason,
});
userResult.value = { ...userResult.value, sanctions: result.sanctions };
banStatus.value = '차단 해제 완료';
await Promise.all([refreshUserHistory(), loadUserDirectory()]);
} catch (error) {
banStatus.value = '차단 해제 실패';
}
};
const resetProfileIcon = async () => {
if (!userResult.value) {
return;
}
const reason = requireUserActionReason();
if (!reason) return;
try {
const result = await adminClient.users.resetProfileIcon.mutate({
userId: userResult.value.id,
reason,
});
userResult.value = {
...userResult.value,
profileIconResetAt: result.profileIconResetAt,
};
profileIconStatus.value = result.flushPublished
? '아이콘 초기화 요청 완료'
: '아이콘은 초기화됐지만 실행 중 서버 알림에 실패했습니다. 다시 요청해 주세요.';
await Promise.all([refreshUserHistory(), loadUserDirectory()]);
} catch (error) {
profileIconStatus.value = '아이콘 초기화 실패';
}
};
const applyRestriction = async () => {
if (!userResult.value) {
return;
}
const reason = requireUserActionReason();
if (!reason) return;
if (!restrictionProfile.value.trim()) {
restrictionStatus.value = '대상 서버를 선택하세요.';
return;
}
const features = restrictionFeatures.value
.split(',')
.map((feature) => feature.trim())
.filter(Boolean);
const restriction = {
blockedFeatures: features.length ? features : undefined,
until: restrictionUntil.value ? serverDateTimeInputToIso(restrictionUntil.value) : undefined,
reason: restrictionReason.value.trim() || undefined,
notes: restrictionNotes.value.trim() || undefined,
};
try {
const result = await adminClient.users.setServerRestriction.mutate({
userId: userResult.value.id,
profile: restrictionProfile.value.trim(),
restriction,
reason,
});
userResult.value = { ...userResult.value, sanctions: result.sanctions };
restrictionStatus.value = '서버 제재 적용 완료';
await Promise.all([refreshUserHistory(), loadUserDirectory()]);
} catch (error) {
restrictionStatus.value = '서버 제재 적용 실패';
}
};
const clearRestriction = async () => {
if (!userResult.value) {
return;
}
const reason = requireUserActionReason();
if (!reason) return;
if (!restrictionProfile.value.trim()) {
restrictionStatus.value = '대상 서버를 선택하세요.';
return;
}
try {
const result = await adminClient.users.setServerRestriction.mutate({
userId: userResult.value.id,
profile: restrictionProfile.value.trim(),
restriction: null,
reason,
});
userResult.value = { ...userResult.value, sanctions: result.sanctions };
restrictionStatus.value = '서버 제재 해제 완료';
await Promise.all([refreshUserHistory(), loadUserDirectory()]);
} catch (error) {
restrictionStatus.value = '서버 제재 해제 실패';
}
};
const scheduleDeleteUser = async () => {
if (!userResult.value) {
return;
}
const reason = requireUserActionReason();
if (!reason) return;
if (typeof window !== 'undefined') {
const confirmed = window.confirm(`${deletionRetentionDays.value}일 보존 후 탈퇴하도록 예약하시겠습니까?`);
if (!confirmed) {
return;
}
}
try {
const result = await adminClient.users.scheduleDeletion.mutate({
userId: userResult.value.id,
retentionDays: deletionRetentionDays.value,
reason,
});
userResult.value = { ...userResult.value, deleteAfter: result.deleteAfter };
forceDeleteStatus.value = `탈퇴 예약 완료: ${formatServerDateTime(result.deleteAfter)}`;
await Promise.all([refreshUserHistory(), loadUserDirectory()]);
} catch (error) {
forceDeleteStatus.value = '탈퇴 예약 실패';
}
};
const createLocalAccount = async () => {
localAccountStatus.value = '';
localAccountResult.value = '';
if (!localAccountEnabled.value) {
setLocalAccountFeedback('ENV 설정이 비활성화 상태입니다.');
return;
}
const username = localAccountForm.value.username.trim();
const password = localAccountForm.value.password.trim();
const displayName = localAccountForm.value.displayName.trim();
if (!username || !password) {
setLocalAccountFeedback('아이디와 비밀번호를 입력하세요.');
return;
}
localAccountLoading.value = true;
try {
const result = await adminClient.users.createLocal.mutate({
username,
password,
displayName: displayName || undefined,
});
localAccountResult.value = `생성됨: ${result.user.username} (${result.user.id})`;
setLocalAccountFeedback('로컬 계정 생성 완료');
localAccountForm.value = {
username: result.user.username,
password: '',
displayName: '',
};
userLookupMode.value = 'username';
userLookupValue.value = result.user.username;
await Promise.all([lookupUser(), loadUserDirectory()]);
} catch (error) {
setLocalAccountFeedback('로컬 계정 생성 실패');
} finally {
localAccountLoading.value = false;
}
};
onMounted(() => {
if (props.section === 'users' || props.section === 'audit' || props.section === 'servers') {
void loadCapabilities();
}
if (props.section === 'users') {
void loadUserDirectory();
void loadLocalAccountStatus();
}
if (props.section === 'system') {
void loadNotice();
}
if (props.section === 'servers') {
void loadProfiles();
}
});
</script>
<template>
<AdminConsoleLayout
:title="currentPage.title"
:description="currentPage.description"
:eyebrow="currentPage.eyebrow"
>
<div class="space-y-8">
<section v-if="section === 'system'" class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">관리자 세션 토큰</h3>
<span class="text-xs text-zinc-500">{{ sessionTokenStatus }}</span>
</div>
<div class="flex flex-col md:flex-row gap-3">
<input
v-model="sessionToken"
type="password"
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-yellow-500"
placeholder="세션 토큰 입력"
/>
<button
class="bg-yellow-600 hover:bg-yellow-500 text-black font-semibold px-4 py-2 rounded"
@click="saveSessionToken"
>
저장
</button>
</div>
</section>
<div class="space-y-8">
<section v-if="section === 'users'" class="grid min-w-0 items-start gap-6 xl:grid-cols-2">
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4 xl:col-span-2">
<div class="flex flex-wrap items-end justify-between gap-2">
<div>
<h3 class="text-lg font-semibold">계정 디렉터리</h3>
<p class="mt-1 text-xs text-zinc-500">
최근 가입 계정을 먼저 보여줍니다. 계정명·표시명·이메일·UUID 일부로 검색할
있습니다.
</p>
</div>
<span class="text-xs text-zinc-400"> {{ userDirectoryTotal }}</span>
</div>
<form class="flex flex-col gap-2 md:flex-row" @submit.prevent="loadUserDirectory(false)">
<input
v-model="userDirectoryQuery"
type="search"
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-yellow-500"
placeholder="계정명, 표시명, 이메일 또는 UUID 검색"
/>
<button
class="bg-blue-700 hover:bg-blue-600 disabled:opacity-50 text-white font-semibold px-4 py-2 rounded"
:disabled="userDirectoryLoading"
>
목록 검색
</button>
</form>
<div v-if="userDirectoryError" class="text-xs text-red-400">{{ userDirectoryError }}</div>
<div
v-if="userDirectory.length"
class="max-h-[28rem] overflow-y-auto rounded border border-zinc-800 bg-zinc-950"
role="region"
aria-label="계정 목록"
>
<button
v-for="user in userDirectory"
:key="user.id"
type="button"
class="grid w-full gap-1 border-b border-zinc-800 px-4 py-3 text-left last:border-b-0 hover:bg-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-yellow-500 md:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_auto] md:items-center"
:class="
userResult?.id === user.id ? 'bg-zinc-900 ring-1 ring-inset ring-yellow-700' : ''
"
@click="selectDirectoryUser(user)"
>
<span class="min-w-0">
<span class="block truncate text-sm font-semibold text-white">{{
user.username
}}</span>
<span class="block truncate text-xs text-zinc-400">{{ user.displayName }}</span>
</span>
<span class="min-w-0 text-xs text-zinc-500">
<span class="block truncate">{{ user.email || '이메일 없음' }}</span>
<span
>{{ user.oauthType }} ·
{{ formatServerDateTime(user.createdAt, { format: 'date' }) }}</span
>
</span>
<span class="flex flex-wrap gap-1 md:justify-end">
<span
v-if="user.hasActiveSanction"
class="rounded bg-red-950 px-2 py-1 text-[11px] text-red-300"
>제재 </span
>
<span
v-if="user.deleteAfter"
class="rounded bg-orange-950 px-2 py-1 text-[11px] text-orange-300"
>탈퇴 예약</span
>
<span class="rounded bg-zinc-800 px-2 py-1 text-[11px] text-zinc-300">{{
user.roles[0] || '역할 없음'
}}</span>
</span>
</button>
</div>
<div
v-else-if="!userDirectoryLoading"
class="rounded border border-dashed border-zinc-700 p-6 text-center text-sm text-zinc-500"
>
조건에 맞는 계정이 없습니다.
</div>
<button
v-if="userDirectoryNextCursor"
type="button"
class="w-full rounded border border-zinc-700 px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
:disabled="userDirectoryLoading"
@click="loadUserDirectory(true)"
>
보기
</button>
<details class="rounded border border-zinc-800 bg-black/20 p-3">
<summary class="cursor-pointer text-xs font-semibold text-zinc-400">
정확한 값으로 직접 열기
</summary>
<form class="space-y-3" @submit.prevent="lookupUser">
<div class="flex flex-col md:flex-row gap-2">
<select
v-model="userLookupMode"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
>
<option value="username">계정명</option>
<option value="email">이메일</option>
<option value="id">UUID</option>
</select>
<input
v-model="userLookupValue"
type="text"
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-yellow-500"
placeholder="검색 값 입력"
/>
<button
class="bg-blue-700 hover:bg-blue-600 text-white font-semibold px-4 py-2 rounded"
:disabled="userLoading"
>
조회
</button>
</div>
<div v-if="userError" class="text-xs text-red-400">{{ userError }}</div>
</form>
</details>
<div v-if="userResult" class="bg-zinc-950 border border-zinc-800 rounded p-4 space-y-2">
<div class="flex justify-between items-center">
<div class="text-sm font-semibold">{{ userResult.username }}</div>
<div class="text-xs text-zinc-500">{{ userResult.id }}</div>
</div>
<div class="text-xs text-zinc-400">표시명: {{ userResult.displayName }}</div>
<div class="text-xs text-zinc-400">권한: {{ userResult.roles.join(', ') || '-' }}</div>
<div class="text-xs text-zinc-500">
OAuth: {{ userResult.oauthType }} {{ userResult.email ?? '' }}
</div>
<div class="text-xs text-zinc-500">
Kakao 인증: {{ userResult.kakaoVerifiedAt ? '완료' : '미완료' }} · 유예 시작:
{{ formatServerDateTime(userResult.kakaoGraceStartedAt) }}
</div>
<div v-if="userResult.kakaoGraceUntil" class="text-xs text-amber-300">
관리자 유예: {{ formatServerDateTime(userResult.kakaoGraceUntil) }}까지
</div>
<div v-if="userResult.kakaoReplacementApprovedUntil" class="text-xs text-amber-300">
Kakao 교체 승인:
{{ formatServerDateTime(userResult.kakaoReplacementApprovedUntil) }}까지
</div>
<div v-if="userResult.deleteAfter" class="text-xs text-red-300">
탈퇴 예약: {{ formatServerDateTime(userResult.deleteAfter) }}
</div>
<div class="text-xs text-zinc-500">
가입일: {{ formatServerDateTime(userResult.createdAt) }}
</div>
<div class="text-xs text-zinc-400 mt-2">제재 상태</div>
<pre class="text-[11px] text-zinc-400 bg-black/50 p-2 rounded whitespace-pre-wrap"
>{{ JSON.stringify(userResult.sanctions, null, 2) }}
</pre>
</div>
</div>
<div
v-if="hasUser"
class="bg-zinc-900 border border-amber-800/70 rounded-lg p-5 space-y-3 xl:col-span-2"
>
<h4 class="text-base font-semibold">민감 조치 공통 사유</h4>
<input
v-model="userActionReason"
type="text"
maxlength="200"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="권한·제재·복구·탈퇴 조치 사유 (필수)"
/>
<div class="text-xs text-zinc-500">사유와 정화된 입력은 관리자 감사 원장에 기록됩니다.</div>
</div>
<nav
v-if="hasUser"
class="grid gap-2 rounded-lg border border-zinc-800 bg-zinc-900 p-2 sm:grid-cols-2 xl:col-span-2 xl:grid-cols-4"
aria-label="사용자 관리 기능"
>
<button
v-for="item in userWorkspaceSections"
:key="item.id"
type="button"
class="rounded px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-yellow-500"
:class="
userWorkspaceSection === item.id
? 'bg-yellow-600 text-black'
: 'bg-zinc-950 text-zinc-300 hover:bg-zinc-800'
"
:aria-current="userWorkspaceSection === item.id ? 'page' : undefined"
@click="userWorkspaceSection = item.id"
>
<span class="block text-sm font-semibold">{{ item.label }}</span>
<span class="mt-1 block text-[11px] opacity-75">{{ item.description }}</span>
</button>
</nav>
<div
v-if="userWorkspaceSection === 'account'"
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4 xl:col-span-2"
>
<div class="flex items-center justify-between">
<h4 class="text-base font-semibold">로컬 계정 생성</h4>
<span class="text-xs text-zinc-500"> ENV {{ localAccountEnabled ? 'ON' : 'OFF' }} </span>
</div>
<div class="text-xs text-zinc-500">카카오 OAuth 없이 로그인 가능한 계정을 생성합니다.</div>
<div class="grid gap-2">
<input
v-model="localAccountForm.username"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="아이디"
:disabled="!localAccountEnabled || localAccountLoading"
/>
<input
v-model="localAccountForm.password"
type="password"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="비밀번호"
:disabled="!localAccountEnabled || localAccountLoading"
/>
<input
v-model="localAccountForm.displayName"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="표시명 (선택)"
:disabled="!localAccountEnabled || localAccountLoading"
/>
<button
class="bg-cyan-600 hover:bg-cyan-500 text-black font-semibold px-4 py-2 rounded"
:disabled="!localAccountEnabled || localAccountLoading"
@click="createLocalAccount"
>
계정 생성
</button>
</div>
<div class="text-xs text-zinc-500">{{ localAccountStatus }}</div>
<div v-if="localAccountResult" class="text-xs text-emerald-400">
{{ localAccountResult }}
</div>
</div>
<div
v-if="userWorkspaceSection === 'account' && hasUser"
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
>
<h4 class="text-base font-semibold">ID · 닉네임 변경</h4>
<p class="text-xs text-zinc-400">
사용자는 직접 바꿀 없습니다. 닉네임은 현재 장수에 반영되며 과거 장수와 명예의 전당의 당시
기록은 유지됩니다.
</p>
<div class="grid gap-2">
<label class="text-xs text-zinc-400" for="admin-identity-username">로그인 ID</label>
<input
id="admin-identity-username"
v-model="identityUsername"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
/>
<label class="text-xs text-zinc-400" for="admin-identity-display-name">닉네임</label>
<input
id="admin-identity-display-name"
v-model="identityDisplayName"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
/>
<button
type="button"
class="bg-blue-700 hover:bg-blue-600 text-white font-semibold px-4 py-2 rounded"
@click="updateUserIdentity"
>
ID · 닉네임 변경
</button>
</div>
<div class="text-xs text-zinc-500">{{ identityStatus }}</div>
</div>
<div
v-if="userWorkspaceSection === 'account' && hasUser && userResult?.oauthType === 'KAKAO'"
class="bg-zinc-900 border border-red-900/70 rounded-lg p-5 space-y-4"
>
<h4 class="text-base font-semibold">Kakao 계정 영구 교체</h4>
<p class="text-xs text-zinc-400">
승인 사용자가 계정 관리에서 Kakao 계정의 소유권을 직접 증명합니다. 교체가 완료되면
기존 Kakao stable ID는 영구 폐기되어 로그인·재가입·재연결에 없습니다.
</p>
<input
v-model="kakaoReplacementUntil"
type="datetime-local"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
aria-label="Kakao 계정 교체 승인 만료 시각"
/>
<div class="flex flex-col gap-2 sm:flex-row">
<button
type="button"
class="flex-1 bg-amber-600 hover:bg-amber-500 text-black font-semibold px-4 py-2 rounded"
@click="setKakaoReplacementApproval(false)"
>
교체 승인
</button>
<button
type="button"
class="flex-1 bg-zinc-700 hover:bg-zinc-600 px-4 py-2 rounded"
@click="setKakaoReplacementApproval(true)"
>
승인 해제
</button>
</div>
<div class="text-xs text-zinc-500">{{ kakaoReplacementStatus }}</div>
</div>
<div
v-if="userWorkspaceSection === 'access'"
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
>
<h4 class="text-base font-semibold">비밀번호 리셋</h4>
<div class="flex flex-col md:flex-row gap-2">
<input
v-model="passwordInput"
type="text"
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="직접 지정 시 입력"
:disabled="!hasUser"
/>
<button
class="bg-emerald-600 hover:bg-emerald-500 text-black font-semibold px-4 py-2 rounded"
:disabled="!hasUser"
@click="resetUserPassword"
>
초기화
</button>
</div>
<div class="text-xs text-zinc-400">임시 비밀번호: {{ passwordResult || '-' }}</div>
<div class="text-xs text-zinc-500">{{ passwordStatus }}</div>
</div>
<div
v-if="userWorkspaceSection === 'access'"
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4 xl:col-span-2"
>
<h4 class="text-base font-semibold">특수 권한 부여</h4>
<div class="grid gap-2 md:grid-cols-[1fr_1fr_auto]">
<select
v-model="selectedCapability"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
:disabled="!hasUser"
>
<option
v-for="capability in capabilities"
:key="capability.permission"
:value="capability.permission"
>
{{ capability.label }} · {{ capability.risk }}
</option>
</select>
<select
v-model="capabilityProfile"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
:disabled="!hasUser || !kakaoPolicies.length"
aria-label="권한 대상 서버"
>
<option value="">대상 서버 선택</option>
<option
v-for="policy in kakaoPolicies"
:key="policy.profileName"
:value="policy.profileName"
>
{{ policy.displayName }}
</option>
</select>
<button
class="bg-zinc-700 hover:bg-zinc-600 px-3 py-2 rounded text-sm"
:disabled="!hasUser"
@click="applyCapabilitySelection"
>
선택 반영
</button>
</div>
<div v-if="selectedCapability" class="text-xs text-zinc-500">
{{ capabilities.find((item) => item.permission === selectedCapability)?.description }}
</div>
<div class="flex flex-col md:flex-row gap-2">
<select
v-model="rolesMode"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
:disabled="!hasUser"
>
<option value="grant">추가</option>
<option value="revoke">제거</option>
<option value="set">덮어쓰기</option>
</select>
<input
v-model="rolesInput"
type="text"
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="admin, moderator 등"
:disabled="!hasUser"
/>
<button
class="bg-indigo-600 hover:bg-indigo-500 text-white font-semibold px-4 py-2 rounded"
:disabled="!hasUser"
@click="updateUserRoles"
>
적용
</button>
</div>
<div class="text-xs text-zinc-500">{{ rolesStatus }}</div>
</div>
<div
v-if="userWorkspaceSection === 'access'"
class="bg-zinc-900 border border-amber-800/60 rounded-lg p-5 space-y-4"
>
<h4 class="text-base font-semibold">Kakao 없는 특수 계정 접근</h4>
<div class="text-xs text-zinc-400">
운영자 role은 자동으로 모든 서버에 접근합니다. 테스트·복구·기타 계정은 아래에서 서버 범위와
만료를 명시해 부여합니다. 복구 자격은 만료가 필수이며 최대 90일입니다. 시각 입력은 서버 시간
UTC+9 기준입니다.
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<select
v-model="specialAccessKind"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
:disabled="!hasUser"
>
<option value="RECOVERY">휴대폰 분실·계정 복구</option>
<option value="TESTER">특수 테스트</option>
<option value="OTHER">기타 예외</option>
</select>
<input
v-model="specialAccessExpiresAt"
type="datetime-local"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
:disabled="!hasUser"
aria-label="특수 접근 만료 시각"
/>
<fieldset class="rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-sm text-white">
<legend class="px-1 text-xs text-zinc-400">허용 서버 (비우면 전체)</legend>
<label
v-for="option in specialAccessScopeOptions"
:key="option.value"
class="mr-4 inline-flex items-center gap-2"
>
<input
v-model="specialAccessProfiles"
type="checkbox"
:value="option.value"
:disabled="!hasUser"
/>
{{ option.label }}
</label>
</fieldset>
<label class="flex items-center gap-2 text-sm text-zinc-300 px-2">
<input
v-model="specialAccessAllowsGeneralCreation"
type="checkbox"
:disabled="!hasUser"
/>
장수 생성 허용
</label>
</div>
<button
class="bg-amber-600 hover:bg-amber-500 text-black font-semibold px-4 py-2 rounded"
:disabled="!hasUser"
@click="grantSpecialAccess"
>
특수 접근 부여
</button>
<div class="text-xs text-zinc-500">{{ specialAccessStatus }}</div>
<div v-if="specialAccessGrants.length" class="space-y-2">
<div
v-for="grant in specialAccessGrants"
:key="grant.id"
class="bg-black/30 border border-zinc-800 rounded p-3 text-xs space-y-1"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="font-semibold text-amber-200">
{{ grant.kind }} ·
{{
grant.profiles.length
? grant.profiles.map(profileScopeLabel).join(', ')
: '전체 서버'
}}
</span>
<button
v-if="!grant.revokedAt"
class="bg-red-900 hover:bg-red-800 text-red-100 px-3 py-1 rounded"
@click="revokeSpecialAccess(grant.id)"
>
해제
</button>
</div>
<div>
장수 생성 {{ grant.allowsGeneralCreation ? '허용' : '차단' }} · 만료
{{ formatServerDateTime(grant.expiresAt, { fallback: '없음' }) }}
</div>
<div class="text-zinc-500">부여 사유: {{ grant.reason }}</div>
<div v-if="grant.revokedAt" class="text-red-300">
해제됨: {{ formatServerDateTime(grant.revokedAt) }} ·
{{ grant.revokedReason }}
</div>
</div>
</div>
</div>
<div
v-if="userWorkspaceSection === 'access'"
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
>
<h4 class="text-base font-semibold">Kakao 인증 유예</h4>
<div class="text-xs text-zinc-500">
기본·서버별 유예가 끝난 사용자를 예외적으로 허용할 사용합니다. 시각 입력은 서버 시간
UTC+9 기준입니다.
</div>
<div class="flex flex-col md:flex-row gap-2">
<input
v-model="kakaoGraceUntil"
type="datetime-local"
class="flex-1 bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
:disabled="!hasUser"
/>
<button
class="bg-yellow-600 hover:bg-yellow-500 text-black px-4 py-2 rounded"
:disabled="!hasUser"
@click="updateKakaoGrace(false)"
>
유예 연장
</button>
<button
class="bg-zinc-700 hover:bg-zinc-600 px-4 py-2 rounded"
:disabled="!hasUser"
@click="updateKakaoGrace(true)"
>
개별 유예 해제
</button>
</div>
<div class="text-xs text-zinc-500">{{ kakaoGraceStatus }}</div>
<div class="overflow-x-auto">
<table class="w-full min-w-[620px] text-xs">
<thead class="text-zinc-500">
<tr>
<th class="p-2 text-left">서버</th>
<th>접근</th>
<th>장수 생성</th>
<th>기본 접근 유예</th>
<th>특수 자격</th>
<th>종료</th>
</tr>
</thead>
<tbody>
<tr
v-for="policy in kakaoPolicies"
:key="policy.profileName"
class="border-t border-zinc-800"
>
<td class="p-2">{{ policy.displayName }}</td>
<td class="text-center">{{ policy.accessAllowed ? '허용' : '차단' }}</td>
<td class="text-center">{{ policy.canCreateGeneral ? '허용' : '차단' }}</td>
<td class="text-center">{{ policy.accessGraceDays }}</td>
<td class="text-center">{{ policy.specialAccess?.kind ?? '-' }}</td>
<td class="text-center">
{{ policy.graceEndsAt ? formatServerDateTime(policy.graceEndsAt) : '-' }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div
v-if="userWorkspaceSection === 'restrictions'"
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
>
<h4 class="text-base font-semibold">유저 차단 (서버 시간 UTC+9)</h4>
<div class="flex flex-col gap-2">
<input
v-model="banUntil"
type="datetime-local"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
:disabled="!hasUser"
/>
<input
v-model="banReason"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="사유"
:disabled="!hasUser"
/>
<div class="flex gap-2">
<button
class="bg-red-600 hover:bg-red-500 text-white font-semibold px-4 py-2 rounded"
:disabled="!hasUser"
@click="applyBan"
>
차단 설정
</button>
<button
class="bg-zinc-700 hover:bg-zinc-600 text-white font-semibold px-4 py-2 rounded"
:disabled="!hasUser"
@click="clearBan"
>
차단 해제
</button>
</div>
</div>
<div class="text-xs text-zinc-500">{{ banStatus }}</div>
</div>
<div
v-if="userWorkspaceSection === 'restrictions'"
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
>
<h4 class="text-base font-semibold">서버별 기능 제재 (서버 시간 UTC+9)</h4>
<div class="grid gap-2">
<select
v-model="restrictionProfile"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
:disabled="!hasUser || !kakaoPolicies.length"
aria-label="제재 대상 서버"
>
<option value="">대상 서버 선택</option>
<option
v-for="policy in kakaoPolicies"
:key="policy.profileName"
:value="policy.profileName"
>
{{ policy.displayName }}
</option>
</select>
<input
v-model="restrictionFeatures"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="login, message 등"
:disabled="!hasUser"
/>
<input
v-model="restrictionUntil"
type="datetime-local"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
:disabled="!hasUser"
/>
<input
v-model="restrictionReason"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="사유"
:disabled="!hasUser"
/>
<input
v-model="restrictionNotes"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="메모"
:disabled="!hasUser"
/>
<div class="flex gap-2">
<button
class="bg-amber-600 hover:bg-amber-500 text-black font-semibold px-4 py-2 rounded"
:disabled="!hasUser"
@click="applyRestriction"
>
제재 적용
</button>
<button
class="bg-zinc-700 hover:bg-zinc-600 text-white font-semibold px-4 py-2 rounded"
:disabled="!hasUser"
@click="clearRestriction"
>
제재 해제
</button>
</div>
</div>
<div class="text-xs text-zinc-500">{{ restrictionStatus }}</div>
</div>
<div
v-if="userWorkspaceSection === 'restrictions'"
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4 xl:col-span-2"
>
<h4 class="text-base font-semibold">프로필 아이콘 초기화</h4>
<button
class="bg-purple-600 hover:bg-purple-500 text-white font-semibold px-4 py-2 rounded"
:disabled="!hasUser"
@click="resetProfileIcon"
>
아이콘 초기화 요청
</button>
<div class="text-xs text-zinc-500">{{ profileIconStatus }}</div>
</div>
<div
v-if="userWorkspaceSection === 'lifecycle'"
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
>
<h4 class="text-base font-semibold text-red-400">관리자 탈퇴 예약</h4>
<input
v-model.number="deletionRetentionDays"
type="number"
min="1"
max="90"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
aria-label="탈퇴 보존 일수"
:disabled="!hasUser"
/>
<button
class="bg-red-700 hover:bg-red-600 text-white font-semibold px-4 py-2 rounded"
:disabled="!hasUser"
@click="scheduleDeleteUser"
>
보존 기간 탈퇴 예약
</button>
<div class="text-xs text-zinc-500">{{ forceDeleteStatus }}</div>
</div>
<div
v-if="userWorkspaceSection === 'lifecycle'"
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
>
<h4 class="text-base font-semibold">사용자 관리자 조치 이력</h4>
<div v-if="!userHistory.length" class="text-xs text-zinc-500">기록이 없습니다.</div>
<div v-else class="max-h-80 overflow-auto space-y-2">
<div
v-for="event in userHistory"
:key="event.id"
class="border border-zinc-800 rounded p-3 text-xs"
>
<div class="flex justify-between gap-3">
<span
:class="
event.outcome === 'FAILED'
? 'text-red-300'
: event.outcome === 'SUCCEEDED'
? 'text-emerald-300'
: 'text-amber-300'
"
>
{{ event.outcome }} · {{ event.action }}
</span>
<span class="text-zinc-500">{{ formatServerDateTime(event.createdAt) }}</span>
</div>
<div class="text-zinc-400">
{{ event.actorUsername }} · {{ event.reason ?? '사유 없음' }}
</div>
<div v-if="event.errorMessage" class="text-red-300">{{ event.errorMessage }}</div>
<pre class="mt-2 overflow-auto whitespace-pre-wrap text-[11px] text-zinc-500">{{
JSON.stringify(event.summary, null, 2)
}}</pre>
</div>
</div>
</div>
</section>
<section v-if="section !== 'users'" class="min-w-0 space-y-6">
<div
v-if="
section === 'audit' && capabilities.some((entry) => entry.permission === 'admin.audit.read')
"
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
>
<div class="flex items-center justify-between gap-3">
<h3 class="text-lg font-semibold">전체 관리자 감사 원장</h3>
<button
class="rounded bg-zinc-700 px-3 py-2 text-xs hover:bg-zinc-600"
@click="loadGlobalAudit"
>
새로고침
</button>
</div>
<div v-if="globalAuditStatus" class="text-xs text-red-400">{{ globalAuditStatus }}</div>
<div v-if="!globalAuditHistory.length" class="text-xs text-zinc-500">기록이 없습니다.</div>
<div v-else class="max-h-96 space-y-2 overflow-auto">
<div
v-for="event in globalAuditHistory"
:key="event.id"
class="rounded border border-zinc-800 p-3 text-xs"
>
<div class="flex justify-between gap-3">
<span
:class="
event.outcome === 'FAILED'
? 'text-red-300'
: event.outcome === 'SUCCEEDED'
? 'text-emerald-300'
: 'text-amber-300'
"
>
{{ event.outcome }} · {{ event.action }}
</span>
<span class="text-zinc-500">{{ formatServerDateTime(event.createdAt) }}</span>
</div>
<div class="text-zinc-400">
{{ event.actorUsername }} · {{ event.targetType ?? '-' }}
{{ auditTargetLabel(event) }} · {{ event.reason ?? '사유 없음' }}
</div>
</div>
</div>
</div>
<div
v-if="
section === 'audit' &&
!capabilities.some((entry) => entry.permission === 'admin.audit.read')
"
class="rounded-lg border border-zinc-800 bg-zinc-900 p-5 text-sm text-zinc-400"
>
감사 로그를 조회할 권한이 없습니다.
</div>
<div
v-if="section === 'system'"
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
>
<div class="flex justify-between items-center">
<h3 class="text-lg font-semibold">서버 공지</h3>
<span class="text-xs text-zinc-500">{{ noticeStatus }}</span>
</div>
<textarea
v-model="noticeDraft"
class="w-full bg-zinc-950 border border-zinc-700 rounded p-3 text-sm text-white min-h-[140px]"
placeholder="로비 공지 입력"
/>
<div class="flex gap-2">
<button
class="bg-blue-700 hover:bg-blue-600 text-white font-semibold px-4 py-2 rounded"
:disabled="noticeLoading"
@click="saveNotice"
>
공지 저장
</button>
<button
class="bg-zinc-700 hover:bg-zinc-600 text-white font-semibold px-4 py-2 rounded"
:disabled="noticeLoading"
@click="loadNotice"
>
다시 불러오기
</button>
</div>
</div>
<div v-if="section === 'servers'" class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">서버별 관리</h3>
<button
class="bg-zinc-700 hover:bg-zinc-600 text-white text-sm px-3 py-1.5 rounded"
:disabled="profilesLoading"
@click="loadProfiles"
>
새로고침
</button>
</div>
<div
v-for="profile in visibleProfiles"
:key="profile.profileName"
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
>
<ServerProfileTabs
:profile-name="profile.profileName"
:profile-label="adminProfileDisplayName(profile)"
active-tab="status"
:can-deploy="hasCapability('admin.profiles.deploy', profile.profileName)"
:can-reset="hasCapability('admin.scenarios.reset', profile.profileName)"
:can-cancel="hasCapability('admin.games.cancel', profile.profileName)"
/>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
<div>
<div class="text-base font-semibold">
{{ adminProfileDisplayName(profile) }}
</div>
<div class="text-xs text-zinc-500">
현재 시나리오: {{ profile.currentScenario ?? '미설정' }}
</div>
<div
class="mt-1 text-xs text-amber-200"
data-testid="profile-lifecycle-description"
>
{{ profileLifecycleText(profile) }}
</div>
</div>
<div class="text-xs text-zinc-400">
상태: {{ profile.status }} / API: {{ profile.runtime.apiRunning ? 'ON' : 'OFF' }} /
DAEMON: {{ profile.runtime.daemonRunning ? 'ON' : 'OFF' }} / AUCTION:
{{ profile.runtime.auctionRunning ? 'ON' : 'OFF' }} / BATTLE SIM:
{{ profile.runtime.battleSimRunning ? 'ON' : 'OFF' }} / TOURNAMENT:
{{ profile.runtime.tournamentRunning ? 'ON' : 'OFF' }}
</div>
</div>
<div class="text-xs text-zinc-400">빌드 커밋: {{ profile.buildCommitSha ?? '미지정' }}</div>
<div
v-if="
hasCapability('admin.scenarios.reset', profile.profileName) &&
canCloseCompletedProfile(profile)
"
class="rounded border border-amber-700/70 bg-amber-950/30 p-3 text-sm text-amber-100"
data-testid="completed-profile-cleanup"
>
<div class="font-semibold">천하통일 서버 정리</div>
<p class="mt-1 text-xs text-amber-200/80">
서버를 외부 목록에서 닫고 실행 프로세스를 중지합니다. 현재 기수 DB와 완료 기록은
보존되며, 다음 시나리오 초기화로 다시 열 수 있습니다.
</p>
<button
class="mt-3 rounded bg-red-700 px-3 py-2 font-semibold text-white hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-40"
data-testid="completed-profile-cleanup-submit"
:disabled="profileActionSubmitting[profile.profileName]"
@click="requestProfileAction(profile.profileName, 'CLOSE_COMPLETED')"
>
완료 서버 닫기
</button>
</div>
<div class="grid md:grid-cols-2 gap-3">
<div
v-if="hasCapability('admin.profiles.settings', profile.profileName)"
class="space-y-2"
>
<label class="text-xs text-zinc-400">표시명</label>
<input
v-model="profileEdits[profile.profileName].korName"
type="text"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
/>
<label class="text-xs text-zinc-400">색상</label>
<input
v-model="profileEdits[profile.profileName].color"
type="text"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
/>
<label class="text-xs text-zinc-400">인게임 공지</label>
<textarea
v-model="profileEdits[profile.profileName].inGameNotice"
class="w-full bg-zinc-950 border border-zinc-700 rounded p-2 text-sm text-white min-h-[80px]"
/>
<label class="text-xs text-zinc-400">프로필 이미지</label>
<input
v-model="profileEdits[profile.profileName].profileImageUrl"
type="text"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
/>
<label class="text-xs text-zinc-400">다음 시즌 번호</label>
<input
v-model="profileEdits[profile.profileName].nextSeasonIdx"
type="number"
min="0"
step="1"
data-testid="next-season-idx"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="예: 12"
/>
<div class="text-xs text-zinc-500">리셋 시 적용할 시즌 번호를 지정합니다.</div>
<label class="text-xs text-zinc-400">첫 기수 번호</label>
<input
v-model="profileEdits[profile.profileName].firstGameIdx"
type="number"
min="0"
step="1"
data-testid="first-game-idx"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="기본값: 1"
/>
<div class="text-xs text-zinc-500">
완료된 게임 수에 더할 첫 기수 번호입니다. 다음 리셋부터 적용되며, 완료 이력이
생긴 뒤 바꾸면 이후 기수 번호가 이동합니다.
</div>
<details class="rounded border border-zinc-700 bg-zinc-950/60 p-3">
<summary class="cursor-pointer text-sm font-semibold text-zinc-200">
서버 리셋 기본 옵션
</summary>
<p class="mt-2 text-xs text-zinc-500">
이 서버의 시나리오 초기화 화면을 열 때 자동으로 채울 값을 저장합니다.
시나리오와 예약·오픈 시각은 실행할 때 선택합니다.
</p>
<div class="mt-3 grid gap-3 text-xs sm:grid-cols-2">
<label>
{{ RESET_OPTION_COPY.turnTerm.label }}
<select
v-model.number="
profileEdits[profile.profileName].resetDefaults.turnTermMinutes
"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2"
data-testid="meta-reset-turn-term"
>
<option
v-for="minutes in PROFILE_TURN_TERM_MINUTES"
:key="minutes"
:value="minutes"
>
{{ minutes }}분
</option>
</select>
</label>
<label>
{{ RESET_OPTION_COPY.joinMode.label }}
<select
v-model="profileEdits[profile.profileName].resetDefaults.joinMode"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2"
>
<option value="full">일반</option>
<option value="onlyRandom">랜덤 임관</option>
</select>
</label>
<label>
{{ RESET_OPTION_COPY.fiction.label }}
<select
v-model.number="
profileEdits[profile.profileName].resetDefaults.fiction
"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2"
>
<option :value="0">연의</option>
<option :value="1">가상</option>
</select>
</label>
<label>
{{ RESET_OPTION_COPY.blockGeneralCreate.label }}
<select
v-model.number="
profileEdits[profile.profileName].resetDefaults
.blockGeneralCreate
"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2"
>
<option :value="0">가능</option>
<option :value="2">장수명 무작위</option>
<option :value="1">불가</option>
</select>
</label>
<label>
{{ RESET_OPTION_COPY.npcMode.label }}
<select
v-model.number="
profileEdits[profile.profileName].resetDefaults.npcMode
"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2"
data-testid="meta-reset-npc-mode"
>
<option :value="0">불가</option>
<option :value="1">가능</option>
<option :value="2">선택 생성 가능</option>
</select>
</label>
<label>
{{ RESET_OPTION_COPY.showImgLevel.label }}
<select
v-model.number="
profileEdits[profile.profileName].resetDefaults.showImgLevel
"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2"
>
<option :value="0">안함</option>
<option :value="1">전콘</option>
<option :value="2">전콘, 병종</option>
<option :value="3">전콘, 병종, NPC</option>
</select>
</label>
<label class="flex items-center gap-2">
<input
v-model="profileEdits[profile.profileName].resetDefaults.sync"
type="checkbox"
/>
{{ RESET_OPTION_COPY.sync.label }} 사용
</label>
<label class="flex items-center gap-2">
<input
v-model="profileEdits[profile.profileName].resetDefaults.extend"
type="checkbox"
/>
{{ RESET_OPTION_COPY.extend.label }} 포함
</label>
<label class="flex items-center gap-2">
<input
v-model="
profileEdits[profile.profileName].resetDefaults.tournamentTrig
"
type="checkbox"
/>
{{ RESET_OPTION_COPY.tournamentTrig.label }}
</label>
<label class="flex items-center gap-2">
<input
v-model="profileEdits[profile.profileName].resetAutorunEnabled"
type="checkbox"
@change="ensureResetAutorun(profile.profileName)"
/>
{{ RESET_OPTION_COPY.autorun.label }}
</label>
<template
v-if="
profileEdits[profile.profileName].resetAutorunEnabled &&
profileEdits[profile.profileName].resetDefaults.autorunUser
"
>
<label>
{{ RESET_OPTION_COPY.autorunLimit.label }}
<input
v-model.number="
profileEdits[profile.profileName].resetDefaults.autorunUser!
.limitMinutes
"
type="number"
min="1"
max="43200"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2"
/>
</label>
<div class="flex flex-wrap items-center gap-3 sm:col-span-2">
<label
v-for="option in RESET_AUTORUN_LABELS"
:key="option.value"
class="flex items-center gap-1"
>
<input
v-model="
profileEdits[profile.profileName].resetDefaults
.autorunUser!.options
"
type="checkbox"
:value="option.value"
/>
{{ option.label }}
</label>
</div>
</template>
</div>
</details>
<label class="text-xs text-zinc-400">Kakao 미인증 접근 유예일</label>
<input
v-model="profileEdits[profile.profileName].localAccountAccessGraceDays"
type="number"
min="0"
max="365"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="비우면 Gateway 기본값"
/>
<label class="text-xs text-zinc-400">Kakao 미인증 장수 생성 유예일</label>
<input
v-model="profileEdits[profile.profileName].localAccountGeneralCreationGraceDays"
type="number"
min="0"
max="365"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="비우면 서버 기본값"
/>
<div class="text-xs text-zinc-500">
게임 규칙 자체가 아니라 Gateway가 game token을 발급할 때 적용하는 profile별 계정
정책입니다.
</div>
<label class="text-xs text-zinc-400">메타 변경 사유</label>
<input
v-model="profileEdits[profile.profileName].reason"
type="text"
maxlength="200"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="변경 사유 (필수)"
/>
<button
class="bg-emerald-600 hover:bg-emerald-500 text-black font-semibold px-4 py-2 rounded"
@click="updateProfileMeta(profile.profileName)"
>
메타 저장
</button>
</div>
<div
v-if="hasCapability('admin.profiles.runtime', profile.profileName)"
class="space-y-2"
>
<fieldset
class="space-y-3 rounded border border-zinc-700 bg-zinc-950/60 p-3"
data-testid="runtime-settings"
>
<legend class="px-1 text-sm font-semibold text-zinc-200">
실행 중 게임 옵션
</legend>
<p class="text-xs text-zinc-500">
현재 기수에 즉시 적용합니다. 턴 간격 변경 시 tick 기준 게임 시각은 같은 게임
시각을 유지하도록 재계산하고, 기존 로그 시각은 유지합니다.
</p>
<p v-if="!profile.runtimeSettings" class="text-xs text-amber-400" role="alert">
게임 DB에서 현재 설정을 읽지 못해 변경할 수 없습니다. 실제 처리 상태를
새로고침해 주세요.
</p>
<label class="block text-xs text-zinc-400">
{{ RESET_OPTION_COPY.turnTerm.label }}
<select
v-model.number="profileActions[profile.profileName].turnTermMinutes"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2 text-sm text-white"
data-testid="runtime-turn-term"
>
<option
v-for="minutes in PROFILE_TURN_TERM_MINUTES"
:key="minutes"
:value="minutes"
>
{{ minutes }}분
</option>
</select>
</label>
<label class="block text-xs text-zinc-400">
{{ RESET_OPTION_COPY.blockGeneralCreate.label }}
<select
v-model.number="profileActions[profile.profileName].blockGeneralCreate"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2 text-sm text-white"
data-testid="runtime-block-general-create"
>
<option :value="0">가능</option>
<option :value="2">장수명 무작위</option>
<option :value="1">불가</option>
</select>
</label>
<label class="flex items-center gap-2 text-xs text-zinc-300">
<input
v-model="profileActions[profile.profileName].autorunEnabled"
type="checkbox"
data-testid="runtime-autorun-enabled"
/>
{{ RESET_OPTION_COPY.autorun.label }} 사용
</label>
<template v-if="profileActions[profile.profileName].autorunEnabled">
<label class="block text-xs text-zinc-400">
{{ RESET_OPTION_COPY.autorunLimit.label }}
<input
v-model="profileActions[profile.profileName].autorunUserMinutes"
type="number"
min="1"
max="43200"
step="1"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-2 text-sm text-white"
data-testid="runtime-autorun-minutes"
/>
</label>
<div class="flex flex-wrap items-center gap-3 text-xs text-zinc-300">
<label
v-for="option in RESET_AUTORUN_LABELS"
:key="`runtime-${option.value}`"
class="flex items-center gap-1"
>
<input
v-model="profileActions[profile.profileName].autorunOptions"
type="checkbox"
:value="option.value"
/>
{{ option.label }}
</label>
</div>
</template>
<button
class="w-full rounded bg-cyan-600 px-4 py-2 font-semibold text-black hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-40"
data-testid="runtime-settings-submit"
:disabled="
profileActionSubmitting[profile.profileName] ||
!profile.runtimeSettings ||
!gatewayProfileCapabilities(profile.status).runtimeExpected ||
!validRuntimeSettings(profile.profileName) ||
runtimeActionPending(profile)
"
@click="
requestProfileAction(profile.profileName, 'UPDATE_RUNTIME_SETTINGS')
"
>
현재 기수에 적용
</button>
</fieldset>
<label class="text-xs text-zinc-400">특수 동작 메모</label>
<input
v-model="profileActions[profile.profileName].reason"
type="text"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="사유 / 메모"
/>
<label
class="text-xs text-zinc-400"
:for="`runtime-duration-${profile.profileName}`"
>
가속/연기 (분)
</label>
<input
:id="`runtime-duration-${profile.profileName}`"
v-model="profileActions[profile.profileName].durationMinutes"
type="number"
min="1"
max="1440"
step="1"
:aria-describedby="`runtime-duration-help-${profile.profileName}`"
:aria-invalid="
profileActions[profile.profileName].durationMinutes
? !validDuration(profile.profileName)
: undefined
"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
/>
<div
:id="`runtime-duration-help-${profile.profileName}`"
class="text-xs text-zinc-500"
>
1~1440 사이의 정수로 입력해 주세요.
</div>
<div class="grid grid-cols-2 gap-2 pt-2">
<button
class="bg-blue-700 hover:bg-blue-600 text-white font-semibold px-3 py-2 rounded disabled:opacity-40 disabled:cursor-not-allowed"
:disabled="
profileActionSubmitting[profile.profileName] ||
!canResumeProfile(profile)
"
@click="requestProfileAction(profile.profileName, 'RESUME')"
>
{{ profile.status === 'PAUSED' ? '턴 재개' : '서버 재개' }}
</button>
<button
class="bg-zinc-700 hover:bg-zinc-600 text-white font-semibold px-3 py-2 rounded disabled:opacity-40 disabled:cursor-not-allowed"
:disabled="
profileActionSubmitting[profile.profileName] ||
!canPauseProfile(profile)
"
@click="requestProfileAction(profile.profileName, 'PAUSE')"
>
일시정지
</button>
<button
class="bg-red-700 hover:bg-red-600 text-white font-semibold px-3 py-2 rounded disabled:opacity-40 disabled:cursor-not-allowed"
:disabled="
profileActionSubmitting[profile.profileName] || !canStopProfile(profile)
"
@click="requestProfileAction(profile.profileName, 'STOP')"
>
중지
</button>
<button
class="bg-orange-600 hover:bg-orange-500 text-black font-semibold px-3 py-2 rounded disabled:opacity-40 disabled:cursor-not-allowed"
:disabled="
profileActionSubmitting[profile.profileName] ||
!validDuration(profile.profileName) ||
runtimeActionPending(profile)
"
@click="requestProfileAction(profile.profileName, 'ACCELERATE')"
>
가속
</button>
<button
class="bg-yellow-600 hover:bg-yellow-500 text-black font-semibold px-3 py-2 rounded disabled:opacity-40 disabled:cursor-not-allowed"
:disabled="
profileActionSubmitting[profile.profileName] ||
!validDuration(profile.profileName) ||
runtimeActionPending(profile)
"
@click="requestProfileAction(profile.profileName, 'DELAY')"
>
연기
</button>
<button
class="bg-black hover:bg-zinc-800 text-white font-semibold px-3 py-2 rounded"
@click="requestProfileAction(profile.profileName, 'SHUTDOWN')"
>
서버 폐쇄
</button>
</div>
<div class="text-xs text-zinc-500">
{{ profileActionStatus[profile.profileName] }}
</div>
<div
v-if="profile.runtimeActions[0]"
class="rounded border border-zinc-800 bg-zinc-950 p-2 text-xs"
role="status"
aria-live="polite"
>
<div :class="runtimeActionStatusClass(profile.runtimeActions[0].status)">
{{ profile.runtimeActions[0].status }} ·
{{ profile.runtimeActions[0].action }}
{{
profile.runtimeActions[0].durationMinutes
? `${profile.runtimeActions[0].durationMinutes}분`
: ''
}}
</div>
<div v-if="profile.runtimeActions[0].detail" class="mt-1 text-zinc-500">
{{ profile.runtimeActions[0].detail }}
</div>
<div
v-if="
profile.runtimeActions[0].handler || profile.runtimeActions[0].handledAt
"
class="mt-1 text-zinc-600"
>
{{ profile.runtimeActions[0].handler || '처리자 미상' }}
{{
profile.runtimeActions[0].handledAt
? ` · ${formatRuntimeActionTime(profile.runtimeActions[0].handledAt)}`
: ''
}}
</div>
</div>
<button type="button" class="text-xs text-zinc-400 underline" @click="loadProfiles">
실제 처리 상태 새로고침
</button>
</div>
</div>
</div>
<div v-if="profileActionStatus.global" class="text-xs text-red-400">
{{ profileActionStatus.global }}
</div>
</div>
</section>
</div>
</div>
</AdminConsoleLayout>
</template>