feat: restore gateway logout and main status
This commit is contained in:
@@ -5,7 +5,9 @@ import { LogCategory, LogScope } from '@sammo-ts/infra';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
import { resolveAccessWindows } from '../../services/generalAccess.js';
|
||||
import { getMyGeneral } from '../shared/general.js';
|
||||
import { resolveNationNotice } from '../nation/shared.js';
|
||||
|
||||
const zGeneralSettings = z.object({
|
||||
tnmt: z.number().int().optional(),
|
||||
@@ -381,4 +383,119 @@ export const generalRouter = router({
|
||||
history: trimRecentRecords(history, input.lastWorldHistoryId),
|
||||
};
|
||||
}),
|
||||
getFrontStatus: authedProcedure.query(async ({ ctx }) => {
|
||||
const me = await getMyGeneral(ctx);
|
||||
const worldState = await ctx.db.worldState.findFirst({
|
||||
orderBy: { id: 'asc' },
|
||||
select: {
|
||||
tickSeconds: true,
|
||||
meta: true,
|
||||
},
|
||||
});
|
||||
if (!worldState) {
|
||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const { scoreStartedAt } = resolveAccessWindows(now, worldState.tickSeconds, worldState.meta);
|
||||
const [onlineAccess, ownNation, latestVote] = await Promise.all([
|
||||
ctx.db.generalAccessLog.findMany({
|
||||
where: {
|
||||
lastRefresh: {
|
||||
gte: scoreStartedAt,
|
||||
},
|
||||
},
|
||||
select: { generalId: true },
|
||||
}),
|
||||
me.nationId > 0
|
||||
? ctx.db.nation.findUnique({
|
||||
where: { id: me.nationId },
|
||||
select: { meta: true },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
ctx.db.votePoll.findFirst({
|
||||
where: {
|
||||
startAt: { lte: now },
|
||||
closedAt: null,
|
||||
OR: [{ endAt: null }, { endAt: { gte: now } }],
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const onlineGeneralIds = onlineAccess.map((entry) => entry.generalId);
|
||||
const onlineGenerals =
|
||||
onlineGeneralIds.length > 0
|
||||
? await ctx.db.general.findMany({
|
||||
where: { id: { in: onlineGeneralIds } },
|
||||
orderBy: { id: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
nationId: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
const nationIds = [...new Set(onlineGenerals.map((general) => general.nationId).filter((id) => id > 0))];
|
||||
const nations =
|
||||
nationIds.length > 0
|
||||
? await ctx.db.nation.findMany({
|
||||
where: { id: { in: nationIds } },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
const nationNames = new Map(nations.map((nation) => [nation.id, nation.name]));
|
||||
const onlineByNation = new Map<number, typeof onlineGenerals>();
|
||||
for (const general of onlineGenerals) {
|
||||
const bucket = onlineByNation.get(general.nationId) ?? [];
|
||||
bucket.push(general);
|
||||
onlineByNation.set(general.nationId, bucket);
|
||||
}
|
||||
const onlineNations = [...onlineByNation.entries()]
|
||||
.sort((left, right) => right[1].length - left[1].length || left[0] - right[0])
|
||||
.map(([nationId]) => `【${nationId === 0 ? '재야' : (nationNames.get(nationId) ?? `세력 ${nationId}`)}】`)
|
||||
.join(', ');
|
||||
const myOnlineGenerals = onlineGenerals
|
||||
.filter((general) => general.nationId === me.nationId)
|
||||
.map((general) => general.name)
|
||||
.join(', ');
|
||||
const myVote = latestVote
|
||||
? await ctx.db.vote.findFirst({
|
||||
where: {
|
||||
voteId: latestVote.id,
|
||||
generalId: me.id,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
: null;
|
||||
const worldMeta = asRecord(worldState.meta);
|
||||
const rawLastExecuted = worldMeta.lastTurnTime ?? worldMeta.turntime;
|
||||
const parsedLastExecuted =
|
||||
typeof rawLastExecuted === 'string' || rawLastExecuted instanceof Date ? new Date(rawLastExecuted) : null;
|
||||
|
||||
return {
|
||||
onlineUserCount: onlineGenerals.length,
|
||||
onlineNations,
|
||||
onlineGenerals: myOnlineGenerals,
|
||||
nationNotice: ownNation ? resolveNationNotice(asRecord(ownNation.meta)) : '',
|
||||
lastExecuted:
|
||||
parsedLastExecuted && Number.isFinite(parsedLastExecuted.getTime())
|
||||
? parsedLastExecuted.toISOString()
|
||||
: null,
|
||||
latestVote: latestVote
|
||||
? {
|
||||
id: latestVote.id,
|
||||
title: latestVote.title,
|
||||
hasVoted: Boolean(myVote),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
|
||||
import type { DatabaseClient, GameApiContext } from '../src/context.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
profile: 'che:default',
|
||||
issuedAt: '2026-07-26T00:00:00.000Z',
|
||||
expiresAt: '2026-07-27T00:00:00.000Z',
|
||||
sessionId: 'front-status-owner',
|
||||
user: {
|
||||
id: 'owner',
|
||||
username: 'owner',
|
||||
displayName: 'Owner',
|
||||
roles: [],
|
||||
},
|
||||
sanctions: {},
|
||||
};
|
||||
|
||||
const buildContext = (options: { auth?: GameSessionTokenPayload | null; hasVoted?: boolean } = {}) =>
|
||||
({
|
||||
auth: options.auth === undefined ? auth : options.auth,
|
||||
db: {
|
||||
general: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
id: 7,
|
||||
userId: 'owner',
|
||||
nationId: 2,
|
||||
})),
|
||||
findMany: vi.fn(async () => [
|
||||
{ id: 7, name: '유비', nationId: 2 },
|
||||
{ id: 8, name: '관우', nationId: 2 },
|
||||
{ id: 9, name: '조조', nationId: 3 },
|
||||
]),
|
||||
},
|
||||
worldState: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
tickSeconds: 3600,
|
||||
meta: {
|
||||
lastTurnTime: '2026-07-26T10:00:00.000Z',
|
||||
},
|
||||
})),
|
||||
},
|
||||
generalAccessLog: {
|
||||
findMany: vi.fn(async () => [{ generalId: 7 }, { generalId: 8 }, { generalId: 9 }]),
|
||||
},
|
||||
nation: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
meta: {
|
||||
notice: '<p>북벌 준비</p>',
|
||||
},
|
||||
})),
|
||||
findMany: vi.fn(async () => [
|
||||
{ id: 2, name: '촉' },
|
||||
{ id: 3, name: '위' },
|
||||
]),
|
||||
},
|
||||
votePoll: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
id: 12,
|
||||
title: '다음 시즌 턴 시간',
|
||||
})),
|
||||
},
|
||||
vote: {
|
||||
findFirst: vi.fn(async () => (options.hasVoted ? { id: 21 } : null)),
|
||||
},
|
||||
} as unknown as DatabaseClient,
|
||||
}) as GameApiContext;
|
||||
|
||||
describe('general.getFrontStatus', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-07-26T10:30:00.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns ref-compatible current-turn online, nation notice, and new vote data', async () => {
|
||||
const context = buildContext();
|
||||
const caller = appRouter.createCaller(context);
|
||||
|
||||
const result = await caller.general.getFrontStatus();
|
||||
|
||||
expect(result).toEqual({
|
||||
onlineUserCount: 3,
|
||||
onlineNations: '【촉】, 【위】',
|
||||
onlineGenerals: '유비, 관우',
|
||||
nationNotice: '<p>북벌 준비</p>',
|
||||
lastExecuted: '2026-07-26T10:00:00.000Z',
|
||||
latestVote: {
|
||||
id: 12,
|
||||
title: '다음 시즌 턴 시간',
|
||||
hasVoted: false,
|
||||
},
|
||||
});
|
||||
expect(context.db.generalAccessLog.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
lastRefresh: {
|
||||
gte: new Date('2026-07-26T10:00:00.000Z'),
|
||||
},
|
||||
},
|
||||
select: { generalId: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('reports that the session-owned general already voted', async () => {
|
||||
const caller = appRouter.createCaller(buildContext({ hasVoted: true }));
|
||||
|
||||
await expect(caller.general.getFrontStatus()).resolves.toMatchObject({
|
||||
latestVote: {
|
||||
id: 12,
|
||||
hasVoted: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('requires a game session and does not expose names or policy publicly', async () => {
|
||||
const caller = appRouter.createCaller(buildContext({ auth: null }));
|
||||
|
||||
await expect(caller.general.getFrontStatus()).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
status: {
|
||||
onlineUserCount: number;
|
||||
onlineNations: string;
|
||||
onlineGenerals: string;
|
||||
nationNotice: string;
|
||||
lastExecuted: string | null;
|
||||
latestVote: {
|
||||
id: number;
|
||||
title: string;
|
||||
hasVoted: boolean;
|
||||
} | null;
|
||||
} | null;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="front-status" aria-label="접속 현황과 국가 방침">
|
||||
<div class="status-row vote-status">
|
||||
<RouterLink v-if="status?.latestVote" to="/survey">
|
||||
<span class="vote-label">설문 진행 중: </span>{{ status.latestVote.title }}
|
||||
</RouterLink>
|
||||
<span v-else class="vote-empty">진행중인 설문 없음</span>
|
||||
</div>
|
||||
<div class="status-row online-nations">접속중인 국가: {{ status?.onlineNations ?? '' }}</div>
|
||||
<div class="status-row online-users">【 접속자 】 {{ status?.onlineGenerals ?? '' }}</div>
|
||||
<div class="status-row nation-notice">
|
||||
<div class="notice-title">【 국가방침 】</div>
|
||||
<!-- 레거시 국가 방침은 같은 저장 형식의 HTML 본문을 그대로 표시한다. -->
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div class="nation-notice-body" v-html="status?.nationNotice ?? ''" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.front-status {
|
||||
width: calc(100% + 48px);
|
||||
margin-left: -24px;
|
||||
background-color: #302016;
|
||||
background-image: url('/image/game/back_walnut.jpg');
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 21px;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
box-sizing: border-box;
|
||||
min-height: 36px;
|
||||
border-top: 1px solid gray;
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
.nation-notice {
|
||||
padding: 7px 0;
|
||||
}
|
||||
|
||||
.notice-title {
|
||||
padding: 0 7px;
|
||||
}
|
||||
|
||||
.nation-notice-body {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.nation-notice-body :deep(p) {
|
||||
min-height: 1em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.vote-status {
|
||||
width: 33.333333%;
|
||||
margin-left: auto;
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vote-status a {
|
||||
color: #fff;
|
||||
text-decoration: gray underline;
|
||||
}
|
||||
|
||||
.vote-label {
|
||||
color: cyan;
|
||||
}
|
||||
|
||||
.vote-empty {
|
||||
color: magenta;
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.vote-status {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -27,10 +27,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
type BoardAccess = Awaited<ReturnType<typeof trpc.board.getAccess.query>>;
|
||||
type ReservedTurnView = Awaited<ReturnType<typeof trpc.turns.reserved.getGeneral.query>>[number];
|
||||
type RecentRecord = Awaited<ReturnType<typeof trpc.general.getRecentRecords.query>>['global'][number];
|
||||
type FrontStatus = Awaited<ReturnType<typeof trpc.general.getFrontStatus.query>>;
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const recordsError = ref<string | null>(null);
|
||||
const frontStatusError = ref<string | null>(null);
|
||||
const realtimeEnabled = ref(true);
|
||||
const realtimeStatus = ref<'idle' | 'connected' | 'paused'>('idle');
|
||||
|
||||
@@ -47,6 +49,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
const globalRecords = ref<RecentRecord[]>([]);
|
||||
const generalRecords = ref<RecentRecord[]>([]);
|
||||
const worldHistory = ref<RecentRecord[]>([]);
|
||||
const frontStatus = ref<FrontStatus | null>(null);
|
||||
const surveyNotice = ref<NonNullable<FrontStatus['latestVote']> | null>(null);
|
||||
let lastGeneralRecordId = 0;
|
||||
let lastWorldHistoryId = 0;
|
||||
let recordGeneralId: number | null = null;
|
||||
@@ -199,6 +203,28 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const updateFrontStatus = (nextStatus: FrontStatus) => {
|
||||
frontStatus.value = nextStatus;
|
||||
const latestVote = nextStatus.latestVote;
|
||||
if (!latestVote || latestVote.hasVoted || typeof window === 'undefined') {
|
||||
surveyNotice.value = null;
|
||||
return;
|
||||
}
|
||||
const serverId = session.profile?.split(':', 1)[0] ?? 'game';
|
||||
const storageKey = `state.${serverId}.lastVote`;
|
||||
const lastSeenVoteId = Number.parseInt(window.localStorage.getItem(storageKey) ?? '0', 10);
|
||||
if (latestVote.id <= (Number.isFinite(lastSeenVoteId) ? lastSeenVoteId : 0)) {
|
||||
surveyNotice.value = null;
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(storageKey, latestVote.id.toString());
|
||||
surveyNotice.value = latestVote;
|
||||
};
|
||||
|
||||
const dismissSurveyNotice = () => {
|
||||
surveyNotice.value = null;
|
||||
};
|
||||
|
||||
const mergeRecentRecords = (current: RecentRecord[], incoming: RecentRecord[]): RecentRecord[] => {
|
||||
const merged = new Map(current.map((entry) => [entry.id, entry]));
|
||||
for (const entry of incoming) {
|
||||
@@ -214,6 +240,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
lastGeneralRecordId = 0;
|
||||
lastWorldHistoryId = 0;
|
||||
recordGeneralId = id;
|
||||
frontStatus.value = null;
|
||||
surveyNotice.value = null;
|
||||
};
|
||||
|
||||
const loadMainData = async () => {
|
||||
@@ -223,6 +251,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
recordsError.value = null;
|
||||
frontStatusError.value = null;
|
||||
|
||||
try {
|
||||
const context = await trpc.general.me.query();
|
||||
@@ -256,19 +285,35 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
recordsError.value = resolveErrorMessage(err);
|
||||
return null;
|
||||
});
|
||||
const [layout, lobby, map, commands, messageData, contacts, access, generalTurns, nationTurns, records] =
|
||||
await Promise.all([
|
||||
layoutPromise,
|
||||
trpc.lobby.info.query(),
|
||||
trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }),
|
||||
trpc.turns.getCommandTable.query({ generalId: id }),
|
||||
trpc.messages.getRecent.query({ generalId: id }),
|
||||
trpc.messages.getContacts.query({ generalId: id }),
|
||||
trpc.board.getAccess.query(),
|
||||
generalTurnsPromise,
|
||||
nationTurnsPromise,
|
||||
recordsPromise,
|
||||
]);
|
||||
const frontStatusPromise = trpc.general.getFrontStatus.query().catch((err: unknown) => {
|
||||
frontStatusError.value = resolveErrorMessage(err);
|
||||
return null;
|
||||
});
|
||||
const [
|
||||
layout,
|
||||
lobby,
|
||||
map,
|
||||
commands,
|
||||
messageData,
|
||||
contacts,
|
||||
access,
|
||||
generalTurns,
|
||||
nationTurns,
|
||||
records,
|
||||
nextFrontStatus,
|
||||
] = await Promise.all([
|
||||
layoutPromise,
|
||||
trpc.lobby.info.query(),
|
||||
trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }),
|
||||
trpc.turns.getCommandTable.query({ generalId: id }),
|
||||
trpc.messages.getRecent.query({ generalId: id }),
|
||||
trpc.messages.getContacts.query({ generalId: id }),
|
||||
trpc.board.getAccess.query(),
|
||||
generalTurnsPromise,
|
||||
nationTurnsPromise,
|
||||
recordsPromise,
|
||||
frontStatusPromise,
|
||||
]);
|
||||
|
||||
mapLayout.value = layout;
|
||||
lobbyInfo.value = lobby;
|
||||
@@ -290,6 +335,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
);
|
||||
lastWorldHistoryId = Math.max(lastWorldHistoryId, records.history[0]?.id ?? 0);
|
||||
}
|
||||
if (nextFrontStatus) {
|
||||
updateFrontStatus(nextFrontStatus);
|
||||
}
|
||||
if (initializedMailboxGeneralId !== id) {
|
||||
targetMailbox.value = MESSAGE_MAILBOX_NATIONAL_BASE + context.general.nationId;
|
||||
initializedMailboxGeneralId = id;
|
||||
@@ -639,6 +687,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
loading,
|
||||
error,
|
||||
recordsError,
|
||||
frontStatusError,
|
||||
realtimeEnabled,
|
||||
realtimeStatus,
|
||||
generalContext,
|
||||
@@ -658,12 +707,15 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
globalRecords,
|
||||
generalRecords,
|
||||
worldHistory,
|
||||
frontStatus,
|
||||
surveyNotice,
|
||||
messageDraftText,
|
||||
targetMailbox,
|
||||
mailboxGroups,
|
||||
statusLine,
|
||||
realtimeLabel,
|
||||
setRealtimeEnabled,
|
||||
dismissSurveyNotice,
|
||||
loadMainData,
|
||||
refreshMessages,
|
||||
sendMessage,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { onUnmounted, ref, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useMediaQuery } from '@vueuse/core';
|
||||
import PanelCard from '../components/ui/PanelCard.vue';
|
||||
@@ -12,6 +12,7 @@ import NationBasicCard from '../components/main/NationBasicCard.vue';
|
||||
import MessagePanel from '../components/main/MessagePanel.vue';
|
||||
import SelectedCityPanel from '../components/main/SelectedCityPanel.vue';
|
||||
import RecordPanel from '../components/main/RecordPanel.vue';
|
||||
import MainFrontStatus from '../components/main/MainFrontStatus.vue';
|
||||
import { formatLog } from '../utils/formatLog';
|
||||
import { useSessionStore } from '../stores/session';
|
||||
import { useMainDashboardStore } from '../stores/mainDashboard';
|
||||
@@ -38,6 +39,7 @@ const {
|
||||
loading,
|
||||
error,
|
||||
recordsError,
|
||||
frontStatusError,
|
||||
realtimeEnabled,
|
||||
general,
|
||||
city,
|
||||
@@ -53,6 +55,8 @@ const {
|
||||
globalRecords,
|
||||
generalRecords,
|
||||
worldHistory,
|
||||
frontStatus,
|
||||
surveyNotice,
|
||||
messageDraftText,
|
||||
targetMailbox,
|
||||
mailboxGroups,
|
||||
@@ -60,6 +64,22 @@ const {
|
||||
realtimeLabel,
|
||||
} = storeToRefs(dashboard);
|
||||
|
||||
let surveyNoticeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
watch(surveyNotice, (notice) => {
|
||||
if (surveyNoticeTimer) {
|
||||
clearTimeout(surveyNoticeTimer);
|
||||
surveyNoticeTimer = null;
|
||||
}
|
||||
if (notice) {
|
||||
surveyNoticeTimer = setTimeout(() => dashboard.dismissSurveyNotice(), 60_000);
|
||||
}
|
||||
});
|
||||
onUnmounted(() => {
|
||||
if (surveyNoticeTimer) {
|
||||
clearTimeout(surveyNoticeTimer);
|
||||
}
|
||||
});
|
||||
|
||||
const reserveGeneralTurn = (payload: { index: number; action: string; args: Record<string, unknown> }) => {
|
||||
void dashboard.setGeneralTurn(payload.index, payload.action, payload.args);
|
||||
};
|
||||
@@ -153,11 +173,22 @@ watch(
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div v-if="frontStatusError" class="front-status-error" role="alert">{{ frontStatusError }}</div>
|
||||
|
||||
<div v-if="session.needsGeneral" class="warning">
|
||||
장수가 아직 생성되지 않았습니다. <RouterLink to="/join">장수 생성/빙의</RouterLink>
|
||||
</div>
|
||||
|
||||
<MainFrontStatus :status="frontStatus" />
|
||||
|
||||
<aside v-if="surveyNotice" class="survey-notice" role="status" aria-live="polite">
|
||||
<div class="survey-notice-title">
|
||||
<strong>설문조사 안내</strong>
|
||||
<button type="button" aria-label="설문조사 알림 닫기" @click="dashboard.dismissSurveyNotice">×</button>
|
||||
</div>
|
||||
<RouterLink to="/survey">새로운 설문조사가 있습니다.</RouterLink>
|
||||
</aside>
|
||||
|
||||
<section v-if="isMobile" class="layout-mobile">
|
||||
<div class="mobile-tabs">
|
||||
<button
|
||||
@@ -453,11 +484,62 @@ button {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.front-status-error {
|
||||
color: #ff8a80;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: #f5d08a;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.survey-notice {
|
||||
position: fixed;
|
||||
z-index: 1080;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
box-sizing: border-box;
|
||||
width: min(350px, calc(100vw - 32px));
|
||||
border: 1px solid rgba(255, 193, 7, 0.75);
|
||||
border-radius: 4px;
|
||||
background: rgba(32, 28, 16, 0.96);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.45);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.survey-notice-title {
|
||||
display: flex;
|
||||
min-height: 35px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid rgba(255, 193, 7, 0.45);
|
||||
padding: 8px 12px;
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.survey-notice-title button {
|
||||
padding: 0 4px;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.survey-notice > a {
|
||||
display: block;
|
||||
padding: 12px;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.survey-notice > a:hover,
|
||||
.survey-notice > a:focus-visible {
|
||||
background: rgba(255, 193, 7, 0.12);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.layout-desktop {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 1.4fr) minmax(320px, 1fr);
|
||||
|
||||
@@ -187,6 +187,24 @@ describe('gateway auth flow', () => {
|
||||
|
||||
expect(validated?.user.username).toBe('tester');
|
||||
});
|
||||
|
||||
it('revokes the gateway session and every linked game session on logout', async () => {
|
||||
const { caller, users, sessions } = buildCaller();
|
||||
const user = await users.createUser({
|
||||
username: 'logout-user',
|
||||
password: 'secretpass',
|
||||
});
|
||||
const session = await sessions.createSession(user);
|
||||
const gameSession = await sessions.createGameSession(session.sessionToken, 'che:default');
|
||||
expect(gameSession).not.toBeNull();
|
||||
|
||||
await caller.auth.logout({ sessionToken: session.sessionToken });
|
||||
|
||||
expect(await sessions.getSession(session.sessionToken)).toBeNull();
|
||||
expect(
|
||||
gameSession ? await sessions.getGameSession(gameSession.profile, gameSession.gameToken) : undefined
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('account self service', () => {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const errorResponse = (path: string, message: string) => ({
|
||||
error: {
|
||||
message,
|
||||
code: -32603,
|
||||
data: {
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
httpStatus: 500,
|
||||
path,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const operationNames = (route: Route): string[] => {
|
||||
const url = new URL(route.request().url());
|
||||
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||
};
|
||||
|
||||
const installLobbyFixture = async (page: Page, options: { failLogout?: boolean } = {}) => {
|
||||
let loggedOut = false;
|
||||
let logoutBody = '';
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('sammo-session-token', 'playwright-session');
|
||||
window.localStorage.setItem('sammo-game-token', 'playwright-game-session');
|
||||
window.localStorage.setItem('sammo-game-profile', 'che:default');
|
||||
});
|
||||
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||
const results = operationNames(route).map((operation) => {
|
||||
if (operation === 'me') {
|
||||
return response(
|
||||
loggedOut
|
||||
? null
|
||||
: {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
displayName: '테스터',
|
||||
roles: [],
|
||||
createdAt: '2026-07-26T00:00:00.000Z',
|
||||
}
|
||||
);
|
||||
}
|
||||
if (operation === 'lobby.notice') {
|
||||
return response('');
|
||||
}
|
||||
if (operation === 'lobby.profiles') {
|
||||
return response([]);
|
||||
}
|
||||
if (operation === 'auth.logout') {
|
||||
logoutBody = route.request().postData() ?? '';
|
||||
if (options.failLogout) {
|
||||
return errorResponse(operation, '로그아웃 서버가 응답하지 않습니다.');
|
||||
}
|
||||
loggedOut = true;
|
||||
return response({ ok: true });
|
||||
}
|
||||
throw new Error(`Unhandled tRPC operation: ${operation}`);
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(results),
|
||||
});
|
||||
});
|
||||
return {
|
||||
logoutBody: () => logoutBody,
|
||||
};
|
||||
};
|
||||
|
||||
test('logs out through the server before clearing all browser session state', async ({ page }) => {
|
||||
const fixture = await installLobbyFixture(page);
|
||||
await page.goto('lobby');
|
||||
|
||||
const logout = page.locator('#btn_logout');
|
||||
await expect(logout).toBeVisible();
|
||||
const before = await logout.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
backgroundColor: style.backgroundColor,
|
||||
border: style.border,
|
||||
fontSize: style.fontSize,
|
||||
lineHeight: style.lineHeight,
|
||||
};
|
||||
});
|
||||
expect(before).toMatchObject({
|
||||
width: 200,
|
||||
height: 48,
|
||||
backgroundColor: 'rgb(48, 48, 48)',
|
||||
border: '0px none rgb(255, 255, 255)',
|
||||
fontSize: '16px',
|
||||
lineHeight: '24px',
|
||||
});
|
||||
|
||||
await logout.hover();
|
||||
await logout.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/gateway\/$/);
|
||||
expect(fixture.logoutBody()).toContain('playwright-session');
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => ({
|
||||
session: window.localStorage.getItem('sammo-session-token'),
|
||||
game: window.localStorage.getItem('sammo-game-token'),
|
||||
profile: window.localStorage.getItem('sammo-game-profile'),
|
||||
}))
|
||||
)
|
||||
.toEqual({ session: null, game: null, profile: null });
|
||||
});
|
||||
|
||||
test('keeps the lobby and every token when server logout fails', async ({ page }) => {
|
||||
await installLobbyFixture(page, { failLogout: true });
|
||||
await page.goto('lobby');
|
||||
|
||||
await page.locator('#btn_logout').click();
|
||||
|
||||
await expect(page).toHaveURL(/\/gateway\/lobby$/);
|
||||
await expect(page.getByRole('alert')).toContainText('로그아웃 서버가 응답하지 않습니다.');
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => ({
|
||||
session: window.localStorage.getItem('sammo-session-token'),
|
||||
game: window.localStorage.getItem('sammo-game-token'),
|
||||
profile: window.localStorage.getItem('sammo-game-profile'),
|
||||
}))
|
||||
)
|
||||
.toEqual({
|
||||
session: 'playwright-session',
|
||||
game: 'playwright-game-session',
|
||||
profile: 'che:default',
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../.
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
testMatch: ['server-operations.spec.ts', 'lobby-admin-navigation.spec.ts'],
|
||||
testMatch: ['server-operations.spec.ts', 'lobby-admin-navigation.spec.ts', 'logout.spec.ts'],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
timeout: 30_000,
|
||||
|
||||
@@ -28,14 +28,13 @@ const profiles = ref<LobbyProfile[]>([]);
|
||||
const profileDetails = ref<Record<string, LobbyInfo | undefined>>({});
|
||||
const profileMapPreviews = ref<Record<string, MapPreviewBundle | undefined>>({});
|
||||
const entryLoading = ref<Record<string, boolean>>({});
|
||||
const logoutLoading = ref(false);
|
||||
const logoutError = ref('');
|
||||
const canAccessAdmin = computed(
|
||||
() =>
|
||||
me.value?.roles.some(
|
||||
(role) =>
|
||||
role === 'superuser' ||
|
||||
role === 'admin' ||
|
||||
role === 'admin.superuser' ||
|
||||
role.startsWith('admin.')
|
||||
role === 'superuser' || role === 'admin' || role === 'admin.superuser' || role.startsWith('admin.')
|
||||
) ?? false
|
||||
);
|
||||
|
||||
@@ -82,9 +81,28 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
const handleLogout = async () => {
|
||||
// TODO: Implement logout mutation in gateway-api
|
||||
// await trpc.auth.logout.mutation();
|
||||
await router.push('/');
|
||||
if (logoutLoading.value) {
|
||||
return;
|
||||
}
|
||||
logoutError.value = '';
|
||||
const sessionToken = window.localStorage.getItem('sammo-session-token');
|
||||
if (!sessionToken) {
|
||||
await router.replace('/');
|
||||
return;
|
||||
}
|
||||
logoutLoading.value = true;
|
||||
try {
|
||||
await trpc.auth.logout.mutate({ sessionToken });
|
||||
window.localStorage.removeItem('sammo-session-token');
|
||||
window.localStorage.removeItem('sammo-game-token');
|
||||
window.localStorage.removeItem('sammo-game-profile');
|
||||
me.value = null;
|
||||
await router.replace('/');
|
||||
} catch (error) {
|
||||
logoutError.value = error instanceof Error ? error.message : '로그아웃에 실패했습니다.';
|
||||
} finally {
|
||||
logoutLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveGameUrl = (path: string, profileName: string, gameToken: string): string | null => {
|
||||
@@ -335,9 +353,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
{{ profileDetails[profile.profileName]?.turnTerm ?? '-' }}분 턴
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-zinc-500 py-8 text-center">
|
||||
지도를 불러오는 중...
|
||||
</div>
|
||||
<div v-else class="text-xs text-zinc-500 py-8 text-center">지도를 불러오는 중...</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-zinc-600 py-8 text-center">- 폐 쇄 중 -</div>
|
||||
</div>
|
||||
@@ -359,10 +375,12 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
비밀번호 & 전콘 & 탈퇴
|
||||
</RouterLink>
|
||||
<button
|
||||
class="bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors"
|
||||
id="btn_logout"
|
||||
class="legacy-logout-button"
|
||||
:disabled="logoutLoading"
|
||||
@click="handleLogout"
|
||||
>
|
||||
로 그 아 웃
|
||||
{{ logoutLoading ? '로그아웃 중…' : '로 그 아 웃' }}
|
||||
</button>
|
||||
<RouterLink
|
||||
v-if="canAccessAdmin"
|
||||
@@ -372,7 +390,45 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
관리자 페이지
|
||||
</RouterLink>
|
||||
</div>
|
||||
<p v-if="logoutError" class="px-6 pb-4 text-center text-sm text-red-400" role="alert">
|
||||
{{ logoutError }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.legacy-logout-button {
|
||||
box-sizing: border-box;
|
||||
width: 200px;
|
||||
height: 48px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: #303030;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-family: Pretendard, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.legacy-logout-button:hover,
|
||||
.legacy-logout-button:focus,
|
||||
.legacy-logout-button:active {
|
||||
background: #303030;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.legacy-logout-button:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.legacy-logout-button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.65;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user