feat(gateway): verify Kakao account ownership
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
const artifactRoot = process.env.KAKAO_OTP_ARTIFACT_DIR ? resolve(process.env.KAKAO_OTP_ARTIFACT_DIR) : null;
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const errorResponse = (path: string, message: string) => ({
|
||||
error: {
|
||||
message,
|
||||
code: -32001,
|
||||
data: {
|
||||
code: 'UNAUTHORIZED',
|
||||
httpStatus: 401,
|
||||
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 { publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
||||
const challenge = {
|
||||
status: 'otp' as const,
|
||||
challengeId: '11111111-1111-4111-8111-111111111111',
|
||||
expiresAt: '2026-08-08T06:00:00.000Z',
|
||||
attemptsRemaining: 3,
|
||||
};
|
||||
|
||||
const installFixture = async (page: Page, source: 'password' | 'oauth') => {
|
||||
let loggedIn = false;
|
||||
let otpAttempts = 0;
|
||||
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||
const operations = operationNames(route);
|
||||
const results = await Promise.all(
|
||||
operations.map(async (operation) => {
|
||||
if (operation === 'me') {
|
||||
return response(
|
||||
loggedIn
|
||||
? {
|
||||
id: 'kakao-otp-user',
|
||||
username: 'kakao-otp-user',
|
||||
displayName: '카카오 인증 사용자',
|
||||
roles: [],
|
||||
picture: 'default.jpg',
|
||||
kakaoVerified: true,
|
||||
kakaoGraceStartedAt: '2026-08-08T00:00:00.000Z',
|
||||
createdAt: '2026-08-08T00:00:00.000Z',
|
||||
}
|
||||
: null
|
||||
);
|
||||
}
|
||||
if (operation === 'lobby.notice') return response('');
|
||||
if (operation === 'lobby.profiles') return response([]);
|
||||
if (operation === 'auth.passwordKey') {
|
||||
return response({ keyId: 'playwright-key', publicKeyPem, algorithm: 'RSA-OAEP-256' });
|
||||
}
|
||||
if (operation === 'auth.login') return response({ ...challenge, successStatus: 'login' });
|
||||
if (operation === 'auth.kakaoExchange') {
|
||||
return response({ ...challenge, successStatus: source === 'oauth' ? 'verified' : 'login' });
|
||||
}
|
||||
if (operation === 'auth.kakaoOtp') {
|
||||
otpAttempts += 1;
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 800));
|
||||
if (otpAttempts === 1) {
|
||||
return errorResponse(operation, '인증 번호가 틀렸습니다. 2회 더 시도할 수 있습니다.');
|
||||
}
|
||||
loggedIn = true;
|
||||
return response({
|
||||
status: 'login',
|
||||
user: {
|
||||
id: 'kakao-otp-user',
|
||||
username: 'kakao-otp-user',
|
||||
displayName: '카카오 인증 사용자',
|
||||
roles: [],
|
||||
picture: 'default.jpg',
|
||||
kakaoVerified: true,
|
||||
kakaoGraceStartedAt: '2026-08-08T00:00:00.000Z',
|
||||
createdAt: '2026-08-08T00:00:00.000Z',
|
||||
},
|
||||
sessionToken: 'verified-session-token',
|
||||
issuedAt: '2026-08-08T05:57:00.000Z',
|
||||
validUntil: '2026-08-18T05:57:00.000Z',
|
||||
});
|
||||
}
|
||||
throw new Error(`Unhandled Kakao OTP fixture operation: ${operation}`);
|
||||
})
|
||||
);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(results),
|
||||
});
|
||||
});
|
||||
return { otpAttempts: () => otpAttempts };
|
||||
};
|
||||
|
||||
const verifyDialog = async (page: Page, artifactName: string) => {
|
||||
const dialog = page.getByRole('dialog', { name: '인증 코드 필요' });
|
||||
const input = dialog.getByLabel('인증 코드');
|
||||
const submit = dialog.getByRole('button', { name: '제출' });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toContainText("카카오톡의 '나와의 채팅'란을 확인해 주세요.");
|
||||
await expect(input).toBeFocused();
|
||||
|
||||
const geometry = await dialog.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
backgroundColor: style.backgroundColor,
|
||||
border: style.border,
|
||||
fontSize: style.fontSize,
|
||||
};
|
||||
});
|
||||
expect(geometry.width).toBeLessThanOrEqual(500);
|
||||
expect(geometry.width).toBeGreaterThan(350);
|
||||
expect(geometry.backgroundColor).toBe('rgb(48, 48, 48)');
|
||||
expect(geometry.border).toBe('1px solid rgb(68, 68, 68)');
|
||||
|
||||
await submit.hover();
|
||||
await page.waitForTimeout(200);
|
||||
expect(await submit.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgb(55, 90, 127)');
|
||||
await input.press('Tab');
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(submit).toBeFocused();
|
||||
await page.waitForTimeout(200);
|
||||
expect(await submit.evaluate((element) => getComputedStyle(element).boxShadow)).toMatch(
|
||||
/^rgba\(85, 115, 146, 0\.49\d\) 0px 0px 0px 4px$/
|
||||
);
|
||||
|
||||
if (artifactRoot) {
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
await page.screenshot({ path: resolve(artifactRoot, `${artifactName}.png`), fullPage: true });
|
||||
await writeFile(
|
||||
resolve(artifactRoot, `${artifactName}.json`),
|
||||
`${JSON.stringify(geometry, null, 2)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
const verifyPointerActive = async () => {
|
||||
const box = await submit.boundingBox();
|
||||
if (!box) throw new Error('OTP submit button has no rendered geometry.');
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
expect(await submit.evaluate((element) => element.matches(':active'))).toBe(true);
|
||||
await page.waitForTimeout(200);
|
||||
expect(await submit.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgb(44, 72, 102)');
|
||||
await page.mouse.move(1, 1);
|
||||
await page.mouse.up();
|
||||
};
|
||||
const submitAndObserveDisabled = () =>
|
||||
submit.evaluate(
|
||||
(element) =>
|
||||
new Promise<{ disabled: boolean; opacity: string }>((resolveDisabled) => {
|
||||
(element as HTMLButtonElement).click();
|
||||
setTimeout(
|
||||
() =>
|
||||
resolveDisabled({
|
||||
disabled: (element as HTMLButtonElement).disabled,
|
||||
opacity: getComputedStyle(element).opacity,
|
||||
}),
|
||||
200
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
await input.fill('0000');
|
||||
await verifyPointerActive();
|
||||
expect(await submitAndObserveDisabled()).toEqual({ disabled: true, opacity: '0.65' });
|
||||
await expect(dialog.getByRole('alert')).toContainText('2회 더 시도');
|
||||
await expect(input).toBeFocused();
|
||||
|
||||
await input.fill('1234');
|
||||
expect(await submitAndObserveDisabled()).toEqual({ disabled: true, opacity: '0.65' });
|
||||
await expect(page).toHaveURL(/\/gateway\/lobby(?:\?verified=1)?$/);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.localStorage.getItem('sammo-session-token')))
|
||||
.toBe('verified-session-token');
|
||||
return geometry;
|
||||
};
|
||||
|
||||
for (const viewport of [
|
||||
{ name: 'desktop', width: 1200, height: 900 },
|
||||
{ name: 'mobile', width: 390, height: 844 },
|
||||
] as const) {
|
||||
test(`completes password-login KakaoTalk OTP on ${viewport.name}`, async ({ page }) => {
|
||||
const fixture = await installFixture(page, 'password');
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto('/gateway/');
|
||||
await page.getByLabel('계정명').fill('kakao-otp-user');
|
||||
await page.getByLabel('비밀번호').fill('password-for-browser-fixture');
|
||||
await page.getByRole('button', { name: '로그인', exact: true }).click();
|
||||
|
||||
const geometry = await verifyDialog(page, `kakao-otp-password-${viewport.name}`);
|
||||
expect(geometry.width).toBe(viewport.name === 'desktop' ? 500 : 374);
|
||||
expect(geometry.y).toBe(viewport.name === 'desktop' ? 28 : 8);
|
||||
expect(fixture.otpAttempts()).toBe(2);
|
||||
});
|
||||
}
|
||||
|
||||
test('completes the same KakaoTalk OTP flow after OAuth callback', async ({ page }) => {
|
||||
const fixture = await installFixture(page, 'oauth');
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await page.goto('/gateway/oauth/callback?code=oauth-code&state=oauth-state');
|
||||
|
||||
await verifyDialog(page, 'kakao-otp-oauth-callback');
|
||||
await expect(page).toHaveURL(/\/gateway\/lobby\?verified=1$/);
|
||||
expect(fixture.otpAttempts()).toBe(2);
|
||||
});
|
||||
@@ -60,6 +60,9 @@ const loginGame = async (username: string, password: string) => {
|
||||
).toString('base64'),
|
||||
};
|
||||
const login = await gateway.auth.login.mutate({ username, credential });
|
||||
if (login.status === 'otp') {
|
||||
throw new Error('Live lifecycle fixture requires a currently verified KakaoTalk login.');
|
||||
}
|
||||
gatewaySession.token = login.sessionToken;
|
||||
const issued = await gateway.auth.issueGameSession.mutate({
|
||||
sessionToken: login.sessionToken,
|
||||
|
||||
@@ -16,6 +16,7 @@ export default defineConfig({
|
||||
'account-icon-sync.spec.ts',
|
||||
'legacy-log-html.spec.ts',
|
||||
'gateway-notice-html.spec.ts',
|
||||
'kakao-otp.spec.ts',
|
||||
],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
|
||||
import { trpc } from '../utils/trpc';
|
||||
|
||||
const props = defineProps<{
|
||||
challengeId: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
verified: [sessionToken: string, validUntil: string];
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
const code = ref('');
|
||||
const errorMessage = ref('');
|
||||
const submitting = ref(false);
|
||||
const codeInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
watch(
|
||||
() => props.challengeId,
|
||||
async () => {
|
||||
code.value = '';
|
||||
errorMessage.value = '';
|
||||
await nextTick();
|
||||
codeInput.value?.focus();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
errorMessage.value = '';
|
||||
submitting.value = true;
|
||||
try {
|
||||
const result = await trpc.auth.kakaoOtp.mutate({
|
||||
challengeId: props.challengeId,
|
||||
code: code.value,
|
||||
});
|
||||
emit('verified', result.sessionToken, result.validUntil);
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '인증 코드를 확인하지 못했습니다.';
|
||||
code.value = '';
|
||||
await nextTick();
|
||||
codeInput.value?.focus();
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="otp-backdrop" @keydown.esc="emit('cancel')">
|
||||
<section
|
||||
class="otp-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="kakao-otp-title"
|
||||
aria-describedby="kakao-otp-description"
|
||||
>
|
||||
<header>
|
||||
<h2 id="kakao-otp-title">인증 코드 필요</h2>
|
||||
<button class="close-button" type="button" aria-label="닫기" @click="emit('cancel')">×</button>
|
||||
</header>
|
||||
<form @submit.prevent="submit">
|
||||
<div id="kakao-otp-description" class="otp-copy">
|
||||
인증 코드가 필요합니다.<br /><br />
|
||||
카카오톡의 '나와의 채팅'란을 확인해 주세요.<br />
|
||||
(별도의 알림[소리, 진동, 숫자]이 발생하지 않습니다.)
|
||||
</div>
|
||||
<label class="otp-input-row" for="kakao-otp-code">
|
||||
<span>인증 코드</span>
|
||||
<input
|
||||
id="kakao-otp-code"
|
||||
ref="codeInput"
|
||||
v-model="code"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]{4}"
|
||||
maxlength="4"
|
||||
autocomplete="one-time-code"
|
||||
placeholder="인증 코드"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<p v-if="errorMessage" class="otp-error" role="alert">{{ errorMessage }}</p>
|
||||
<footer>
|
||||
<button class="cancel-button" type="button" @click="emit('cancel')">취소</button>
|
||||
<button class="submit-button" type="submit" :disabled="submitting">
|
||||
{{ submitting ? '확인 중…' : '제출' }}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.otp-backdrop {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
inset: 0;
|
||||
overflow-y: auto;
|
||||
background: rgb(0 0 0 / 60%);
|
||||
}
|
||||
|
||||
.otp-dialog {
|
||||
width: min(calc(100% - 16px), 500px);
|
||||
margin: 28px auto;
|
||||
overflow: hidden;
|
||||
border: 1px solid #444;
|
||||
border-radius: 5px;
|
||||
background: #303030;
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 45%);
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.otp-dialog {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.otp-dialog header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #555;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.otp-dialog h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
width: 26px;
|
||||
height: 30px;
|
||||
border: 1px solid #aaa;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.otp-copy {
|
||||
padding: 18px 18px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.otp-input-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
margin: 22px 16px 0;
|
||||
}
|
||||
|
||||
.otp-input-row span,
|
||||
.otp-input-row input {
|
||||
border: 1px solid #000;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.otp-input-row span {
|
||||
border-radius: 4px 0 0 4px;
|
||||
background: #303030;
|
||||
color: #adb5bd;
|
||||
}
|
||||
|
||||
.otp-input-row input {
|
||||
min-width: 0;
|
||||
border-left: 0;
|
||||
border-radius: 0 4px 4px 0;
|
||||
background: #ddd;
|
||||
color: #303030;
|
||||
}
|
||||
|
||||
.otp-input-row input:focus-visible {
|
||||
outline: 0;
|
||||
border-color: #9a9a9a;
|
||||
box-shadow: 0 0 0 4px rgb(55 90 127 / 25%);
|
||||
}
|
||||
|
||||
.close-button:focus-visible {
|
||||
outline: 2px solid #375a7f;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.otp-error {
|
||||
margin: 8px 18px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.otp-error {
|
||||
color: #ff8a80;
|
||||
}
|
||||
|
||||
.otp-dialog footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
border-top: 1px solid #555;
|
||||
padding: 15px 16px;
|
||||
}
|
||||
|
||||
.otp-dialog footer button {
|
||||
min-width: 64px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
padding: 7px 12px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s ease-in-out,
|
||||
background-color 0.15s ease-in-out,
|
||||
border-color 0.15s ease-in-out,
|
||||
box-shadow 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
border-color: #444 !important;
|
||||
background: #444;
|
||||
}
|
||||
|
||||
.cancel-button:active {
|
||||
background: #363636;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
border-color: #325172 !important;
|
||||
background: #375a7f;
|
||||
}
|
||||
|
||||
.submit-button:hover {
|
||||
background: #375a7f;
|
||||
}
|
||||
|
||||
.submit-button:focus-visible {
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 4px rgb(85 115 146 / 50%);
|
||||
}
|
||||
|
||||
.submit-button:active {
|
||||
background: #2c4866;
|
||||
}
|
||||
|
||||
.submit-button:disabled {
|
||||
border-color: #375a7f !important;
|
||||
background: #375a7f;
|
||||
cursor: pointer;
|
||||
opacity: 0.65;
|
||||
}
|
||||
</style>
|
||||
@@ -5,6 +5,7 @@ import type { inferRouterOutputs } from '@trpc/server';
|
||||
import type { AppRouter } from '@sammo-ts/gateway-api';
|
||||
|
||||
import MapPreview from '../components/MapPreview.vue';
|
||||
import KakaoOtpDialog from '../components/KakaoOtpDialog.vue';
|
||||
import DefaultLayout from '../layouts/DefaultLayout.vue';
|
||||
import { createGameTrpc, type GameRouter } from '../utils/gameTrpc';
|
||||
import { trpc } from '../utils/trpc';
|
||||
@@ -24,6 +25,7 @@ const username = ref('');
|
||||
const password = ref('');
|
||||
const loginError = ref('');
|
||||
const loginLoading = ref(false);
|
||||
const otpChallenge = ref<{ challengeId: string; expiresAt: string; attemptsRemaining: number } | null>(null);
|
||||
const statusLoading = ref(false);
|
||||
const statusError = ref('');
|
||||
const profile = ref<LobbyProfile | null>(null);
|
||||
@@ -92,6 +94,10 @@ const handleLogin = async (): Promise<void> => {
|
||||
username: username.value,
|
||||
credential,
|
||||
});
|
||||
if (result.status === 'otp') {
|
||||
otpChallenge.value = result;
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem('sammo-session-token', result.sessionToken);
|
||||
await router.push('/lobby');
|
||||
} catch (error) {
|
||||
@@ -101,6 +107,12 @@ const handleLogin = async (): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOtpVerified = async (sessionToken: string): Promise<void> => {
|
||||
window.localStorage.setItem('sammo-session-token', sessionToken);
|
||||
otpChallenge.value = null;
|
||||
await router.push('/lobby');
|
||||
};
|
||||
|
||||
const handleKakao = async (): Promise<void> => {
|
||||
loginError.value = '';
|
||||
try {
|
||||
@@ -191,6 +203,12 @@ const handlePasswordReset = async (): Promise<void> => {
|
||||
</section>
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
<KakaoOtpDialog
|
||||
v-if="otpChallenge"
|
||||
:challenge-id="otpChallenge.challengeId"
|
||||
@verified="handleOtpVerified"
|
||||
@cancel="otpChallenge = null"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import DefaultLayout from '../layouts/DefaultLayout.vue';
|
||||
import KakaoOtpDialog from '../components/KakaoOtpDialog.vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { sealPassword } from '../utils/passwordEnvelope';
|
||||
|
||||
@@ -21,6 +22,8 @@ const displayName = ref('');
|
||||
const termsAgreed = ref(false);
|
||||
const privacyAgreed = ref(false);
|
||||
const thirdPartyUse = ref(false);
|
||||
const otpChallenge = ref<{ challengeId: string; expiresAt: string; attemptsRemaining: number } | null>(null);
|
||||
const otpSuccessStatus = ref<'login' | 'verified'>('login');
|
||||
const appBase = import.meta.env.BASE_URL;
|
||||
|
||||
const completeExchange = async (): Promise<void> => {
|
||||
@@ -33,6 +36,11 @@ const completeExchange = async (): Promise<void> => {
|
||||
}
|
||||
try {
|
||||
const result = await trpc.auth.kakaoExchange.mutate({ code, state });
|
||||
if (result.status === 'otp') {
|
||||
otpChallenge.value = result;
|
||||
otpSuccessStatus.value = result.successStatus;
|
||||
return;
|
||||
}
|
||||
if (result.status === 'login') {
|
||||
window.localStorage.setItem('sammo-session-token', result.sessionToken);
|
||||
await router.replace('/lobby');
|
||||
@@ -78,6 +86,12 @@ const register = async (): Promise<void> => {
|
||||
privacyAgreed: true,
|
||||
thirdPartyUse: thirdPartyUse.value,
|
||||
});
|
||||
if (result.status === 'otp') {
|
||||
otpChallenge.value = result;
|
||||
otpSuccessStatus.value = result.successStatus;
|
||||
oauthSessionId.value = '';
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem('sammo-session-token', result.sessionToken);
|
||||
await router.replace('/lobby');
|
||||
} catch (error) {
|
||||
@@ -87,6 +101,12 @@ const register = async (): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOtpVerified = async (sessionToken: string): Promise<void> => {
|
||||
window.localStorage.setItem('sammo-session-token', sessionToken);
|
||||
otpChallenge.value = null;
|
||||
await router.replace(otpSuccessStatus.value === 'verified' ? '/lobby?verified=1' : '/lobby');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void completeExchange();
|
||||
});
|
||||
@@ -120,7 +140,13 @@ onMounted(() => {
|
||||
<div class="form-row">
|
||||
<label for="oauth-display-name">닉네임</label>
|
||||
<div>
|
||||
<input id="oauth-display-name" v-model="displayName" minlength="2" maxlength="40" required />
|
||||
<input
|
||||
id="oauth-display-name"
|
||||
v-model="displayName"
|
||||
minlength="2"
|
||||
maxlength="40"
|
||||
required
|
||||
/>
|
||||
<small>깃수가 종료될 때 공개됩니다. 계속 사용할 이름이므로 신중하게 정해주세요.</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,6 +180,12 @@ onMounted(() => {
|
||||
</section>
|
||||
</main>
|
||||
</DefaultLayout>
|
||||
<KakaoOtpDialog
|
||||
v-if="otpChallenge"
|
||||
:challenge-id="otpChallenge.challengeId"
|
||||
@verified="handleOtpVerified"
|
||||
@cancel="otpChallenge = null"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user