feat: 관리자 프로필에서 플레이 감사 진입 연결

This commit is contained in:
2026-09-16 03:25:03 +00:00
parent 8f3852662e
commit b12a899526
5 changed files with 180 additions and 32 deletions
@@ -0,0 +1,38 @@
import { writeGameSessionTransfer } from '@sammo-ts/common/auth/gameSessionTransfer';
// 기존 로비의 query fallback은 유지하되 새 관리자 진입은 sessionStorage 전달만 허용한다.
export const resolveGameUrl = (
path: string,
profileName: string,
gameToken: string,
allowLegacyQueryTransfer = true
): string | null => {
const profile = profileName.split(':', 1)[0] ?? profileName;
const baseUrl =
import.meta.env.VITE_GAME_WEB_URL ??
import.meta.env.VITE_GAME_WEB_URL_TEMPLATE?.replaceAll('{profile}', encodeURIComponent(profile)) ??
'';
if (!baseUrl) {
return null;
}
const base = new URL(baseUrl, window.location.origin);
const normalizedPath = path.replace(/^\//, '');
const url = new URL(normalizedPath, base);
let transferredInSessionStorage = false;
if (url.origin === window.location.origin) {
try {
transferredInSessionStorage = writeGameSessionTransfer(window.sessionStorage, {
profile: profileName,
gatewayToken: gameToken,
});
} catch {
transferredInSessionStorage = false;
}
}
if (!transferredInSessionStorage) {
if (!allowLegacyQueryTransfer) return null;
url.searchParams.set('profile', profileName);
url.searchParams.set('gameToken', gameToken);
}
return url.toString();
};
@@ -19,6 +19,7 @@ import {
type ResetAutorunOption,
} from '../utils/resetDefaults';
import { trpc } from '../utils/trpc';
import { resolveGameUrl } from '../utils/gameEntry';
type AdminSection = 'users' | 'servers' | 'system' | 'audit';
@@ -571,6 +572,29 @@ const rolesInput = ref('');
const rolesMode = ref<'set' | 'grant' | 'revoke'>('grant');
const rolesStatus = ref('');
const capabilities = ref<AdminCapability[]>([]);
const auditEntryLoading = ref<Record<string, boolean>>({});
const auditEntryError = ref<Record<string, string>>({});
const enterPlayAudit = async (profileName: string): Promise<void> => {
if (auditEntryLoading.value[profileName] || !hasCapability('admin.playAudit.read', profileName)) return;
auditEntryLoading.value[profileName] = true;
auditEntryError.value[profileName] = '';
try {
const sessionToken = window.localStorage.getItem('sammo-session-token');
if (!sessionToken) throw new Error('로그인 후 다시 시도해 주세요.');
const issued = await trpc.auth.issueGameSession.mutate({ sessionToken, profile: profileName });
const url = resolveGameUrl('/play-audit', issued.profile, issued.gameToken, false);
if (!url)
throw new Error(
'게임 주소 또는 세션 전달을 확인하지 못했습니다. 같은 사이트에서 쿠키·저장소를 허용한 뒤 다시 시도해 주세요.'
);
window.location.assign(url);
} catch (cause) {
auditEntryError.value[profileName] =
cause instanceof Error ? cause.message : '플레이 감사 화면에 연결하지 못했습니다.';
} finally {
auditEntryLoading.value[profileName] = false;
}
};
const hasCapability = (permission: string, profileName?: string): boolean =>
capabilities.value.some((entry) => {
if (entry.permission !== permission) return false;
@@ -2418,6 +2442,23 @@ onMounted(() => {
:can-reset="hasCapability('admin.scenarios.reset', profile.profileName)"
:can-cancel="hasCapability('admin.games.cancel', profile.profileName)"
/>
<div v-if="hasCapability('admin.playAudit.read', profile.profileName)">
<button
type="button"
class="rounded bg-sky-700 px-3 py-1.5 text-sm font-semibold text-white hover:bg-sky-600 disabled:opacity-50"
:disabled="auditEntryLoading[profile.profileName]"
@click="enterPlayAudit(profile.profileName)"
>
{{ auditEntryLoading[profile.profileName] ? '연결 중…' : '플레이 감사' }}
</button>
<p
v-if="auditEntryError[profile.profileName]"
role="alert"
class="mt-2 text-sm text-red-300"
>
{{ auditEntryError[profile.profileName] }}
</p>
</div>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
<div>
+1 -31
View File
@@ -4,7 +4,7 @@ import { computed, ref, onMounted, onUnmounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import type { inferRouterOutputs } from '@trpc/server';
import type { AppRouter } from '@sammo-ts/gateway-api';
import { writeGameSessionTransfer } from '@sammo-ts/common/auth/gameSessionTransfer';
import { resolveGameUrl } from '../utils/gameEntry';
import DefaultLayout from '../layouts/DefaultLayout.vue';
import MapPreview from '../components/MapPreview.vue';
import ProfilePreopenAnnouncement from '../components/ProfilePreopenAnnouncement.vue';
@@ -338,36 +338,6 @@ const handleKakaoVerification = async (): Promise<void> => {
}
};
const resolveGameUrl = (path: string, profileName: string, gameToken: string): string | null => {
const profile = profileName.split(':', 1)[0] ?? profileName;
const baseUrl =
import.meta.env.VITE_GAME_WEB_URL ??
import.meta.env.VITE_GAME_WEB_URL_TEMPLATE?.replaceAll('{profile}', encodeURIComponent(profile)) ??
'';
if (!baseUrl) {
return null;
}
const base = new URL(baseUrl, window.location.origin);
const normalizedPath = path.replace(/^\//, '');
const url = new URL(normalizedPath, base);
let transferredInSessionStorage = false;
if (url.origin === window.location.origin) {
try {
transferredInSessionStorage = writeGameSessionTransfer(window.sessionStorage, {
profile: profileName,
gatewayToken: gameToken,
});
} catch {
transferredInSessionStorage = false;
}
}
if (!transferredInSessionStorage) {
url.searchParams.set('profile', profileName);
url.searchParams.set('gameToken', gameToken);
}
return url.toString();
};
const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
if (entryLoading.value[profile.profileName]) {
return;