Add alpha session gate with cookie auth and login UI.
This commit is contained in:
@@ -9,6 +9,19 @@ const ru = {
|
||||
pingPlaceholder: '-- мс',
|
||||
language: 'Язык',
|
||||
|
||||
sessionTitle: 'Вход',
|
||||
sessionPassword: 'Пароль альфы',
|
||||
sessionPasswordPlaceholder: 'Пароль',
|
||||
sessionUserName: 'Ваше имя',
|
||||
sessionUserNamePlaceholder: 'Имя в сети',
|
||||
sessionContinue: 'Продолжить',
|
||||
sessionEnter: 'Войти',
|
||||
sessionBadPassword: 'Неверный пароль.',
|
||||
sessionNameOnline: 'Это имя уже в сети.',
|
||||
sessionInvalidName: 'Имя должно быть от 1 до 40 символов.',
|
||||
sessionFailed: 'Не удалось войти. Попробуйте ещё раз.',
|
||||
sessionLogout: 'Выйти',
|
||||
|
||||
schoolsTitle: 'Школы',
|
||||
createSchool: 'Создать школу',
|
||||
settings: 'Настройки',
|
||||
@@ -338,6 +351,19 @@ const en: Messages = {
|
||||
pingPlaceholder: '-- ms',
|
||||
language: 'Language',
|
||||
|
||||
sessionTitle: 'Sign in',
|
||||
sessionPassword: 'Alpha password',
|
||||
sessionPasswordPlaceholder: 'Password',
|
||||
sessionUserName: 'Your name',
|
||||
sessionUserNamePlaceholder: 'Display name',
|
||||
sessionContinue: 'Continue',
|
||||
sessionEnter: 'Enter',
|
||||
sessionBadPassword: 'Wrong password.',
|
||||
sessionNameOnline: 'That name is already online.',
|
||||
sessionInvalidName: 'The name must be 1–40 characters.',
|
||||
sessionFailed: 'Could not sign in. Try again.',
|
||||
sessionLogout: 'Sign out',
|
||||
|
||||
schoolsTitle: 'Schools',
|
||||
createSchool: 'Create school',
|
||||
settings: 'Settings',
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { GameConnection, gameSocketUrl, type ConnectionStatus } from './net/connection.ts';
|
||||
import { logoutSession } from './net/api.ts';
|
||||
import { getLocale, onLocaleChange } from './i18n/locale.ts';
|
||||
import { t, type MessageKey } from './i18n/strings.ts';
|
||||
import { GameScreen } from './ui/gameScreen.ts';
|
||||
import { localeSwitch } from './ui/localeSwitch.ts';
|
||||
import { MainMenu } from './ui/mainMenu.ts';
|
||||
import { ensureSession } from './ui/sessionGate.ts';
|
||||
import type { School } from './net/api.ts';
|
||||
import './style.css';
|
||||
|
||||
@@ -15,7 +17,7 @@ const STATUS_KEYS: Record<ConnectionStatus, MessageKey> = {
|
||||
};
|
||||
|
||||
/** Wires the two screens to one WebSocket connection. */
|
||||
function bootstrap(): void {
|
||||
async function bootstrap(): Promise<void> {
|
||||
const app = requireElement('#app');
|
||||
const footer = requireElement('#status');
|
||||
const statusLabel = document.querySelector<HTMLElement>('[data-status="connection"]');
|
||||
@@ -23,11 +25,16 @@ function bootstrap(): void {
|
||||
|
||||
footer.prepend(localeSwitch());
|
||||
|
||||
await ensureSession();
|
||||
|
||||
let openSchool: School | null = null;
|
||||
let connectionStatus: ConnectionStatus = 'connecting';
|
||||
let lastPingMs: number | null = null;
|
||||
|
||||
const menu = new MainMenu({ onOpenSchool: (school) => enterSchool(school) });
|
||||
const menu = new MainMenu({
|
||||
onOpenSchool: (school) => enterSchool(school),
|
||||
onLogout: () => void handleLogout(),
|
||||
});
|
||||
const game = new GameScreen({
|
||||
onLeave: () => leaveSchool(),
|
||||
onSetRunning: (running) => connection.setRunning(running),
|
||||
@@ -97,6 +104,15 @@ function bootstrap(): void {
|
||||
menu.start();
|
||||
}
|
||||
|
||||
async function handleLogout(): Promise<void> {
|
||||
connection.close();
|
||||
menu.stop();
|
||||
await logoutSession();
|
||||
await ensureSession();
|
||||
connection.connect();
|
||||
showMenu();
|
||||
}
|
||||
|
||||
onLocaleChange(() => {
|
||||
paintChrome();
|
||||
menu.localize();
|
||||
@@ -120,4 +136,4 @@ function requireElement(selector: string): HTMLElement {
|
||||
return element;
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
void bootstrap();
|
||||
|
||||
@@ -402,6 +402,26 @@ export async function fetchGameStatus(): Promise<GameStatus> {
|
||||
return request<GameStatus>('/api/status');
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
readonly userName: string;
|
||||
}
|
||||
|
||||
export async function fetchSession(): Promise<SessionInfo> {
|
||||
return request<SessionInfo>('/api/session');
|
||||
}
|
||||
|
||||
export async function loginSession(password: string, userName: string): Promise<SessionInfo> {
|
||||
return request<SessionInfo>('/api/session', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ password, userName }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function logoutSession(): Promise<void> {
|
||||
await request<void>('/api/session', { method: 'DELETE' }, { expectBody: false });
|
||||
}
|
||||
|
||||
export interface SwarmUiKindPreset {
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -764,7 +784,7 @@ async function request<T>(
|
||||
init?: RequestInit,
|
||||
options: { expectBody?: boolean } = {},
|
||||
): Promise<T> {
|
||||
const response = await fetch(url, init);
|
||||
const response = await fetch(url, { credentials: 'include', ...init });
|
||||
|
||||
if (!response.ok) {
|
||||
throw await toApiError(response);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { SchoolCard } from './schoolCard.ts';
|
||||
|
||||
interface MainMenuOptions {
|
||||
readonly onOpenSchool: (school: School) => void;
|
||||
readonly onLogout: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,6 +41,10 @@ export class MainMenu {
|
||||
class: 'button',
|
||||
type: 'button',
|
||||
});
|
||||
private readonly logoutButton = el('button', {
|
||||
class: 'button',
|
||||
type: 'button',
|
||||
});
|
||||
|
||||
private readonly limitHint = el('p', { class: 'hint' });
|
||||
private readonly status = el('p', { class: 'hint hint--error' });
|
||||
@@ -53,6 +58,7 @@ export class MainMenu {
|
||||
constructor(private readonly options: MainMenuOptions) {
|
||||
this.createButton.addEventListener('click', () => void this.openCreateDialog());
|
||||
this.settingsButton.addEventListener('click', () => void swarmUiSettingsDialog());
|
||||
this.logoutButton.addEventListener('click', () => this.options.onLogout());
|
||||
this.status.hidden = true;
|
||||
|
||||
this.root.append(
|
||||
@@ -60,7 +66,7 @@ export class MainMenu {
|
||||
'header',
|
||||
{ class: 'screen__header' },
|
||||
this.title,
|
||||
el('div', { class: 'screen__actions' }, this.settingsButton, this.createButton),
|
||||
el('div', { class: 'screen__actions' }, this.logoutButton, this.settingsButton, this.createButton),
|
||||
),
|
||||
this.limitHint,
|
||||
this.status,
|
||||
@@ -80,6 +86,7 @@ export class MainMenu {
|
||||
this.title.textContent = t('schoolsTitle');
|
||||
this.createButton.textContent = t('createSchool');
|
||||
this.settingsButton.textContent = t('settings');
|
||||
this.logoutButton.textContent = t('sessionLogout');
|
||||
this.emptyHint.textContent = t('emptySchools');
|
||||
|
||||
for (const card of this.cards.values()) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ApiError, fetchSchools, fetchSession } from '../net/api.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { ensureSession } from './sessionGate.ts';
|
||||
|
||||
vi.mock('../net/api.ts', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../net/api.ts')>();
|
||||
return {
|
||||
...actual,
|
||||
fetchSession: vi.fn(),
|
||||
fetchSchools: vi.fn(),
|
||||
loginSession: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('session gate', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<main id="app"></main>';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows the password step when there is no session cookie', async () => {
|
||||
vi.mocked(fetchSession).mockRejectedValue(new ApiError(401, 'unknown', 'Unauthorized'));
|
||||
|
||||
const pending = ensureSession();
|
||||
await Promise.resolve();
|
||||
|
||||
const app = document.querySelector('#app');
|
||||
expect(app?.textContent).toContain(t('sessionPassword'));
|
||||
expect(app?.textContent).not.toContain(t('schoolsTitle'));
|
||||
|
||||
pending.catch(() => undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { ApiError, fetchSession, loginSession } from '../net/api.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { el } from './dom.ts';
|
||||
|
||||
/**
|
||||
* Blocks until the player has a live session cookie. Returns the signed-in name.
|
||||
*/
|
||||
export async function ensureSession(): Promise<string> {
|
||||
try {
|
||||
const session = await fetchSession();
|
||||
return session.userName;
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApiError) || error.status !== 401) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return showSessionGate();
|
||||
}
|
||||
|
||||
function showSessionGate(): Promise<string> {
|
||||
const app = document.querySelector<HTMLElement>('#app');
|
||||
if (app === null) {
|
||||
throw new Error('#app is missing from index.html.');
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let password = '';
|
||||
const root = el('section', { class: 'screen session-gate' });
|
||||
const title = el('h1', { class: 'screen__title' });
|
||||
const passwordLabel = el('label', { class: 'field' });
|
||||
const passwordInput = el('input', {
|
||||
class: 'field__input',
|
||||
type: 'password',
|
||||
autocomplete: 'current-password',
|
||||
}) as HTMLInputElement;
|
||||
const nameLabel = el('label', { class: 'field' });
|
||||
const nameInput = el('input', {
|
||||
class: 'field__input',
|
||||
type: 'text',
|
||||
autocomplete: 'username',
|
||||
maxlength: '40',
|
||||
}) as HTMLInputElement;
|
||||
const submit = el('button', { class: 'button button--primary', type: 'submit' });
|
||||
const error = el('p', { class: 'hint hint--error' });
|
||||
error.hidden = true;
|
||||
|
||||
passwordLabel.append(el('span', { class: 'field__label' }), passwordInput);
|
||||
nameLabel.append(el('span', { class: 'field__label' }), nameInput);
|
||||
nameLabel.hidden = true;
|
||||
|
||||
const form = el(
|
||||
'form',
|
||||
{ class: 'session-gate__form' },
|
||||
passwordLabel,
|
||||
nameLabel,
|
||||
error,
|
||||
submit,
|
||||
);
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
void submitStep();
|
||||
});
|
||||
|
||||
root.append(title, form);
|
||||
app.replaceChildren(root);
|
||||
paint();
|
||||
|
||||
async function submitStep(): Promise<void> {
|
||||
error.hidden = true;
|
||||
submit.toggleAttribute('disabled', true);
|
||||
|
||||
try {
|
||||
if (nameLabel.hidden) {
|
||||
password = passwordInput.value;
|
||||
nameLabel.hidden = false;
|
||||
submit.textContent = t('sessionEnter');
|
||||
nameInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await loginSession(password, nameInput.value.trim());
|
||||
resolve(session.userName);
|
||||
} catch (caught) {
|
||||
if (caught instanceof ApiError) {
|
||||
if (caught.code === 'bad-password') {
|
||||
error.textContent = t('sessionBadPassword');
|
||||
passwordInput.focus();
|
||||
} else if (caught.code === 'invalid-name') {
|
||||
error.textContent = t('sessionInvalidName');
|
||||
nameInput.focus();
|
||||
} else if (caught.code === 'name-online') {
|
||||
error.textContent = t('sessionNameOnline');
|
||||
nameInput.focus();
|
||||
} else {
|
||||
error.textContent = t('sessionFailed');
|
||||
}
|
||||
} else {
|
||||
error.textContent = t('sessionFailed');
|
||||
}
|
||||
|
||||
error.hidden = false;
|
||||
} finally {
|
||||
submit.toggleAttribute('disabled', false);
|
||||
}
|
||||
}
|
||||
|
||||
function paint(): void {
|
||||
title.textContent = t('sessionTitle');
|
||||
passwordLabel.querySelector('.field__label')!.textContent = t('sessionPassword');
|
||||
passwordInput.placeholder = t('sessionPasswordPlaceholder');
|
||||
nameLabel.querySelector('.field__label')!.textContent = t('sessionUserName');
|
||||
nameInput.placeholder = t('sessionUserNamePlaceholder');
|
||||
submit.textContent = nameLabel.hidden ? t('sessionContinue') : t('sessionEnter');
|
||||
}
|
||||
|
||||
passwordInput.focus();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user