merge: 최신 main을 Profile 포괄 권한 제거에 통합
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
@@ -6,6 +8,43 @@ const operationNames = (route: Route): string[] => {
|
||||
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||
};
|
||||
|
||||
const { publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
||||
|
||||
const installPasswordSetupFixture = async (page: Page) => {
|
||||
const calls: string[] = [];
|
||||
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||
const results = operationNames(route).map((operation) => {
|
||||
calls.push(operation);
|
||||
if (operation === 'me') return response(null);
|
||||
if (operation === 'lobby.notice') return response('');
|
||||
if (operation === 'lobby.profiles') return response([]);
|
||||
if (operation === 'auth.passwordKey') {
|
||||
return response({ keyId: 'password-setup-key', publicKeyPem, algorithm: 'RSA-OAEP-256' });
|
||||
}
|
||||
if (operation === 'auth.kakaoExchange') {
|
||||
return response({
|
||||
status: 'password_setup',
|
||||
oauthSessionId: '11111111-1111-4111-8111-111111111112',
|
||||
email: 'migrated@example.test',
|
||||
successStatus: 'login',
|
||||
});
|
||||
}
|
||||
if (operation === 'auth.kakaoSetPassword') {
|
||||
return response({
|
||||
status: 'otp',
|
||||
challengeId: '11111111-1111-4111-8111-111111111111',
|
||||
expiresAt: '2026-08-17T12:03:00.000Z',
|
||||
attemptsRemaining: 3,
|
||||
});
|
||||
}
|
||||
throw new Error(`Unhandled password setup fixture operation: ${operation}`);
|
||||
});
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
|
||||
});
|
||||
return calls;
|
||||
};
|
||||
|
||||
const installFixture = async (page: Page, action: 'link_existing' | 'rejoin') => {
|
||||
const calls: string[] = [];
|
||||
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||
@@ -108,4 +147,27 @@ for (const viewport of [
|
||||
expect(calls.filter((operation) => operation === 'auth.kakaoResolveAccount')).toHaveLength(1);
|
||||
expect(geometry.width).toBe(viewport.name === 'desktop' ? 698 : 372);
|
||||
});
|
||||
|
||||
test(`sets a migrated password before opening the OTP dialog on ${viewport.name}`, async ({ page }) => {
|
||||
const calls = await installPasswordSetupFixture(page);
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto('/gateway/oauth/callback?code=oauth-code&state=oauth-state');
|
||||
|
||||
const form = page.getByRole('form', { name: '새 비밀번호 설정' });
|
||||
await expect(form).toBeVisible();
|
||||
await expect(form).toContainText('카카오 인증으로 기존 계정을 확인했습니다.');
|
||||
await expect(form.getByLabel('카카오 이메일')).toHaveValue('migrated@example.test');
|
||||
const geometry = await form.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { width: rect.width, right: rect.right };
|
||||
});
|
||||
await form.getByLabel('새 비밀번호').fill('new-password-value');
|
||||
await form.getByLabel('비밀번호 확인').fill('new-password-value');
|
||||
await form.getByRole('button', { name: '새 비밀번호 설정' }).click();
|
||||
|
||||
await expect(page.getByRole('dialog', { name: '인증 코드 필요' })).toBeVisible();
|
||||
expect(calls.filter((operation) => operation === 'auth.kakaoSetPassword')).toHaveLength(1);
|
||||
expect(geometry.width).toBeGreaterThan(300);
|
||||
expect(geometry.right).toBeLessThanOrEqual(viewport.width);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -219,7 +219,13 @@ test('shows one public map panel and switches it by hover, click, and keyboard',
|
||||
await expect(tablist.getByRole('tab', { name: '퀘섭' })).toHaveCount(0);
|
||||
await expect(panel).toHaveCount(1);
|
||||
await expect(cheTab).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(panel).toContainText('유저 11 / 500');
|
||||
await expect(panel.getByText('체섭', { exact: true })).toHaveCount(0);
|
||||
await expect(panel.locator('.map-preview-title')).toHaveCount(0);
|
||||
const dateBar = panel.locator('.map-preview-date-bar');
|
||||
await expect(dateBar).toHaveText('200년 1월');
|
||||
const summary = panel.getByTestId('public-map-preview-summary');
|
||||
await expect(summary).toContainText('RUNNING');
|
||||
await expect(summary).toContainText('유저 11 / 500');
|
||||
|
||||
const roadLayer = panel.getByTestId('map-preview-road');
|
||||
const castles = panel.getByTestId('map-preview-castle');
|
||||
@@ -251,6 +257,32 @@ test('shows one public map panel and switches it by hover, click, and keyboard',
|
||||
largeCastle: { width: 32, height: 24 },
|
||||
largeNationBackground: { width: 96, height: 72 },
|
||||
});
|
||||
const dateGeometry = await dateBar.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const bodyRect = element.nextElementSibling?.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
const textStyle = getComputedStyle(element.firstElementChild!);
|
||||
return {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
bottom: rect.bottom,
|
||||
bodyTop: bodyRect?.top,
|
||||
backgroundColor: style.backgroundColor,
|
||||
justifyContent: style.justifyContent,
|
||||
fontSize: textStyle.fontSize,
|
||||
lineHeight: textStyle.lineHeight,
|
||||
};
|
||||
});
|
||||
expect(dateGeometry).toEqual({
|
||||
width: 700,
|
||||
height: 20,
|
||||
bottom: dateGeometry.bodyTop,
|
||||
bodyTop: dateGeometry.bodyTop,
|
||||
backgroundColor: 'rgb(0, 0, 0)',
|
||||
justifyContent: 'center',
|
||||
fontSize: '14px',
|
||||
lineHeight: '20px',
|
||||
});
|
||||
if (requestedAssets) {
|
||||
await expect.poll(() => requestedAssets.has('/game/map/che/che_road.png')).toBe(true);
|
||||
await expect.poll(() => requestedAssets.has('/game/cast_8.gif')).toBe(true);
|
||||
@@ -297,7 +329,9 @@ test('shows one public map panel and switches it by hover, click, and keyboard',
|
||||
|
||||
await hweTab.hover();
|
||||
await expect(hweTab).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(panel).toContainText('유저 22 / 500');
|
||||
await expect(panel.getByText('훼섭', { exact: true })).toHaveCount(0);
|
||||
await expect(summary).toContainText('PAUSED');
|
||||
await expect(summary).toContainText('유저 22 / 500');
|
||||
|
||||
await cheTab.click();
|
||||
await expect(cheTab).toHaveAttribute('aria-selected', 'true');
|
||||
@@ -322,7 +356,7 @@ test('shows one public map panel and switches it by hover, click, and keyboard',
|
||||
await page.screenshot({ path: testInfo.outputPath('public-map-tabs-desktop.png'), fullPage: true });
|
||||
await testInfo.attach('public-map-tabs-desktop-geometry', {
|
||||
body: Buffer.from(
|
||||
`${JSON.stringify({ panel: geometry, map: mapGeometry, tooltip: tooltipGeometry }, null, 2)}\n`
|
||||
`${JSON.stringify({ panel: geometry, date: dateGeometry, map: mapGeometry, tooltip: tooltipGeometry }, null, 2)}\n`
|
||||
),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
@@ -344,13 +378,20 @@ test.describe('touch navigation', () => {
|
||||
await hweTab.tap();
|
||||
await expect(hweTab).toHaveAttribute('aria-selected', 'true');
|
||||
const panel = page.getByTestId('public-map-preview-panel');
|
||||
await expect(panel).toContainText('유저 22 / 500');
|
||||
await expect(panel.getByText('훼섭', { exact: true })).toHaveCount(0);
|
||||
await expect(panel.locator('.map-preview-title')).toHaveCount(0);
|
||||
await expect(panel.locator('.map-preview-date-bar')).toHaveText('200년 1월');
|
||||
const summary = panel.getByTestId('public-map-preview-summary');
|
||||
await expect(summary).toContainText('PAUSED');
|
||||
await expect(summary).toContainText('유저 22 / 500');
|
||||
await expect(panel.getByTestId('map-preview-road')).toBeVisible();
|
||||
await expect(panel.getByTestId('map-preview-castle')).toHaveCount(2);
|
||||
const mapBox = await panel.locator('.map-preview-body').boundingBox();
|
||||
expect(mapBox?.width).toBeGreaterThan(280);
|
||||
expect(mapBox?.width).toBeLessThanOrEqual(366);
|
||||
expect(mapBox?.height).toBeCloseTo((mapBox?.width ?? 0) * (5 / 7), 0);
|
||||
const horizontalOverflow = await panel.evaluate((element) => element.scrollWidth - element.clientWidth);
|
||||
expect(horizontalOverflow).toBe(0);
|
||||
const rightCity = panel.getByTestId('map-preview-city').nth(1);
|
||||
await rightCity.hover();
|
||||
const tooltip = panel.getByTestId('map-preview-city-tooltip');
|
||||
|
||||
@@ -276,8 +276,7 @@ const stateClass = (state: number): string => {
|
||||
|
||||
<template>
|
||||
<div class="map-preview" :class="`map-preview-${props.mode}`">
|
||||
<div class="map-preview-header">
|
||||
<span class="map-preview-title">{{ props.mapLayout.mapName }}</span>
|
||||
<div class="map-preview-date-bar">
|
||||
<span class="map-preview-date">{{ props.mapData.year }}년 {{ props.mapData.month }}월</span>
|
||||
</div>
|
||||
<div ref="mapBody" class="map-preview-body" :style="{ backgroundImage: `url('${mapBackground}')` }">
|
||||
@@ -358,18 +357,22 @@ const stateClass = (state: number): string => {
|
||||
width: min(100%, 700px);
|
||||
margin-inline: auto;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.map-preview-header {
|
||||
.map-preview-date-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.7rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
height: 20px;
|
||||
justify-content: center;
|
||||
background: #000;
|
||||
color: rgba(232, 221, 196, 0.82);
|
||||
}
|
||||
|
||||
.map-preview-title {
|
||||
font-weight: 600;
|
||||
.map-preview-date {
|
||||
display: block;
|
||||
width: 160px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.map-preview-body {
|
||||
|
||||
@@ -700,12 +700,6 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
class="map-preview-panel"
|
||||
data-testid="public-map-preview-panel"
|
||||
>
|
||||
<div class="flex items-center justify-between text-xs text-zinc-400 mb-2">
|
||||
<span class="font-semibold" :style="{ color: selectedMapProfile.color }">
|
||||
{{ selectedMapProfile.korName }}섭
|
||||
</span>
|
||||
<span>{{ selectedMapProfile.status }}</span>
|
||||
</div>
|
||||
<div v-if="selectedMapPreview">
|
||||
<MapPreview
|
||||
:map-data="selectedMapPreview.mapData"
|
||||
@@ -714,12 +708,18 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
/>
|
||||
<div
|
||||
v-if="profileDetails[selectedMapProfile.profileName]"
|
||||
class="text-xs text-zinc-400 mt-2"
|
||||
class="map-preview-summary"
|
||||
data-testid="public-map-preview-summary"
|
||||
>
|
||||
유저 {{ profileDetails[selectedMapProfile.profileName]?.userCnt ?? '-' }} /
|
||||
{{ profileDetails[selectedMapProfile.profileName]?.maxUserCnt ?? '-' }} ·
|
||||
{{ profileDetails[selectedMapProfile.profileName]?.nationCnt ?? '-' }}국 ·
|
||||
{{ profileDetails[selectedMapProfile.profileName]?.turnTerm ?? '-' }}분 턴
|
||||
<span class="map-preview-runtime-status" :style="{ color: selectedMapProfile.color }">
|
||||
{{ selectedMapProfile.status }}
|
||||
</span>
|
||||
<span>
|
||||
유저 {{ profileDetails[selectedMapProfile.profileName]?.userCnt ?? '-' }} /
|
||||
{{ profileDetails[selectedMapProfile.profileName]?.maxUserCnt ?? '-' }}
|
||||
</span>
|
||||
<span>{{ profileDetails[selectedMapProfile.profileName]?.nationCnt ?? '-' }}국</span>
|
||||
<span>{{ profileDetails[selectedMapProfile.profileName]?.turnTerm ?? '-' }}분 턴</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-zinc-500 py-8 text-center">지도를 불러오는 중...</div>
|
||||
@@ -832,6 +832,26 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.map-preview-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px 0;
|
||||
margin-top: 8px;
|
||||
color: #a1a1aa;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.map-preview-summary > span + span::before {
|
||||
margin-inline: 6px;
|
||||
color: #52525b;
|
||||
content: '·';
|
||||
}
|
||||
|
||||
.map-preview-runtime-status {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.legacy-logout-button:hover,
|
||||
.legacy-logout-button:focus,
|
||||
.legacy-logout-button:active {
|
||||
|
||||
@@ -14,6 +14,8 @@ const submitting = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const infoMessage = ref('');
|
||||
const oauthSessionId = ref('');
|
||||
const passwordSetupSessionId = ref('');
|
||||
const passwordSetupSuccessStatus = ref<'login' | 'verified'>('login');
|
||||
const email = ref('');
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
@@ -60,6 +62,12 @@ const completeExchange = async (): Promise<void> => {
|
||||
infoMessage.value = '카카오톡으로 임시 비밀번호를 보냈습니다.';
|
||||
return;
|
||||
}
|
||||
if (result.status === 'password_setup') {
|
||||
passwordSetupSessionId.value = result.oauthSessionId;
|
||||
passwordSetupSuccessStatus.value = result.successStatus;
|
||||
email.value = result.email;
|
||||
return;
|
||||
}
|
||||
if (result.status === 'account_recovery') {
|
||||
accountRecovery.value = result;
|
||||
email.value = result.email;
|
||||
@@ -94,6 +102,12 @@ const resolveAccount = async (): Promise<void> => {
|
||||
await router.replace('/lobby');
|
||||
return;
|
||||
}
|
||||
if (result.status === 'password_setup') {
|
||||
passwordSetupSessionId.value = result.oauthSessionId;
|
||||
passwordSetupSuccessStatus.value = result.successStatus;
|
||||
email.value = result.email;
|
||||
return;
|
||||
}
|
||||
oauthSessionId.value = result.oauthSessionId;
|
||||
email.value = result.email;
|
||||
} catch (error) {
|
||||
@@ -103,6 +117,36 @@ const resolveAccount = async (): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
const setMigratedPassword = async (): Promise<void> => {
|
||||
errorMessage.value = '';
|
||||
if (password.value !== confirmPassword.value) {
|
||||
errorMessage.value = '비밀번호 확인이 일치하지 않습니다.';
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const credential = await sealPassword(password.value);
|
||||
const result = await trpc.auth.kakaoSetPassword.mutate({
|
||||
oauthSessionId: passwordSetupSessionId.value,
|
||||
credential,
|
||||
});
|
||||
password.value = '';
|
||||
confirmPassword.value = '';
|
||||
passwordSetupSessionId.value = '';
|
||||
if (result.status === 'otp') {
|
||||
otpChallenge.value = result;
|
||||
otpSuccessStatus.value = passwordSetupSuccessStatus.value;
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem('sammo-session-token', result.sessionToken);
|
||||
await router.replace(result.status === 'verified' ? '/lobby?verified=1' : '/lobby');
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '새 비밀번호를 설정하지 못했습니다.';
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (): Promise<void> => {
|
||||
errorMessage.value = '';
|
||||
if (password.value !== confirmPassword.value) {
|
||||
@@ -156,7 +200,15 @@ onMounted(() => {
|
||||
<main id="oauth-container">
|
||||
<h1>삼국지 모의전투 HiDCHe</h1>
|
||||
<section class="oauth-card">
|
||||
<h2>{{ accountRecovery ? '카카오 계정 연결 확인' : '회원가입' }}</h2>
|
||||
<h2>
|
||||
{{
|
||||
accountRecovery
|
||||
? '카카오 계정 연결 확인'
|
||||
: passwordSetupSessionId
|
||||
? '새 비밀번호 설정'
|
||||
: '회원가입'
|
||||
}}
|
||||
</h2>
|
||||
<p v-if="loading" class="oauth-message">카카오 인증을 확인하는 중...</p>
|
||||
<p v-else-if="infoMessage" class="oauth-message" role="status">{{ infoMessage }}</p>
|
||||
<div v-else-if="accountRecovery" class="recovery-panel" role="group" aria-label="카카오 계정 연결 확인">
|
||||
@@ -181,6 +233,46 @@ onMounted(() => {
|
||||
<RouterLink class="back-link" to="/">취소</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
v-else-if="passwordSetupSessionId"
|
||||
class="password-setup-form"
|
||||
aria-label="새 비밀번호 설정"
|
||||
@submit.prevent="setMigratedPassword"
|
||||
>
|
||||
<p class="oauth-message">
|
||||
카카오 인증으로 기존 계정을 확인했습니다. 앞으로 사용할 새 비밀번호를 설정해 주세요.
|
||||
</p>
|
||||
<div class="form-row">
|
||||
<label for="migrated-password-email">카카오 이메일</label>
|
||||
<input id="migrated-password-email" :value="email" readonly />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="migrated-password">새 비밀번호</label>
|
||||
<input
|
||||
id="migrated-password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
minlength="6"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="migrated-password-confirm">비밀번호 확인</label>
|
||||
<input
|
||||
id="migrated-password-confirm"
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
minlength="6"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button class="register-button" type="submit" :disabled="submitting">
|
||||
{{ submitting ? '설정 중...' : '새 비밀번호 설정' }}
|
||||
</button>
|
||||
<RouterLink class="back-link" to="/">취소</RouterLink>
|
||||
</form>
|
||||
<form v-else-if="oauthSessionId" @submit.prevent="register">
|
||||
<div class="form-row">
|
||||
<label for="oauth-email">카카오 이메일</label>
|
||||
|
||||
Reference in New Issue
Block a user