Add account auth with websocket login/register and sessions

This commit is contained in:
Jage9
2026-02-24 22:03:10 -05:00
parent 1938f239e6
commit bf3bc90f2a
21 changed files with 1053 additions and 24 deletions

View File

@@ -9,12 +9,41 @@
<main class="app">
<h1>Chat Grid</h1>
<div id="connectionStatus" role="status" aria-live="polite" aria-atomic="true"></div>
<section id="loginView" class="auth-panel">
<h2>Login</h2>
<div class="auth-row">
<label for="authUsername">Username</label>
<input id="authUsername" type="text" maxlength="32" autocomplete="username" />
</div>
<div class="auth-row">
<label for="authPassword">Password</label>
<input id="authPassword" type="password" maxlength="64" autocomplete="current-password" />
</div>
<button id="showRegisterButton" type="button">Create account</button>
</section>
<section id="registerView" class="auth-panel hidden">
<h2>Register</h2>
<div class="auth-row">
<label for="registerUsername">Username</label>
<input id="registerUsername" type="text" maxlength="32" autocomplete="username" />
</div>
<div class="auth-row">
<label for="registerPassword">Password</label>
<input id="registerPassword" type="password" maxlength="64" autocomplete="new-password" />
</div>
<div class="auth-row">
<label for="registerEmail">Email (optional)</label>
<input id="registerEmail" type="email" maxlength="320" autocomplete="email" />
</div>
<button id="showLoginButton" type="button">Back to login</button>
</section>
<div id="nicknameContainer" class="nickname-row">
<label for="preconnectNickname">Nickname</label>
<input id="preconnectNickname" type="text" maxlength="32" autocomplete="nickname" />
</div>
<div class="controls" id="button-container">
<button id="connectButton">Connect</button>
<button id="logoutButton">Log out</button>
<button id="settingsButton">Settings</button>
<button id="disconnectButton" class="hidden">Disconnect</button>
<button id="focusGridButton" class="hidden" aria-controls="gameCanvas">Chat Grid</button>

View File

@@ -1,5 +1,5 @@
// Maintainer-controlled web client version.
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
window.CHGRID_WEB_VERSION = "2026.02.25 R242";
window.CHGRID_WEB_VERSION = "2026.02.25 R243";
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
window.CHGRID_TIME_ZONE = "America/Detroit";

View File

@@ -90,12 +90,22 @@ declare global {
type Dom = {
connectionStatus: HTMLElement;
appVersion: HTMLElement;
loginView: HTMLElement;
registerView: HTMLElement;
authUsername: HTMLInputElement;
authPassword: HTMLInputElement;
registerUsername: HTMLInputElement;
registerPassword: HTMLInputElement;
registerEmail: HTMLInputElement;
showRegisterButton: HTMLButtonElement;
showLoginButton: HTMLButtonElement;
updatesSection: HTMLElement;
updatesToggle: HTMLButtonElement;
updatesPanel: HTMLDivElement;
nicknameContainer: HTMLDivElement;
preconnectNickname: HTMLInputElement;
connectButton: HTMLButtonElement;
logoutButton: HTMLButtonElement;
disconnectButton: HTMLButtonElement;
focusGridButton: HTMLButtonElement;
settingsButton: HTMLButtonElement;
@@ -113,12 +123,22 @@ type Dom = {
const dom: Dom = {
connectionStatus: requiredById('connectionStatus'),
appVersion: requiredById('appVersion'),
loginView: requiredById('loginView'),
registerView: requiredById('registerView'),
authUsername: requiredById('authUsername'),
authPassword: requiredById('authPassword'),
registerUsername: requiredById('registerUsername'),
registerPassword: requiredById('registerPassword'),
registerEmail: requiredById('registerEmail'),
showRegisterButton: requiredById('showRegisterButton'),
showLoginButton: requiredById('showLoginButton'),
updatesSection: requiredById('updatesSection'),
updatesToggle: requiredById('updatesToggle'),
updatesPanel: requiredById('updatesPanel'),
nicknameContainer: requiredById('nicknameContainer'),
preconnectNickname: requiredById('preconnectNickname'),
connectButton: requiredById('connectButton'),
logoutButton: requiredById('logoutButton'),
disconnectButton: requiredById('disconnectButton'),
focusGridButton: requiredById('focusGridButton'),
settingsButton: requiredById('settingsButton'),
@@ -203,6 +223,10 @@ let lastFocusedElement: Element | null = null;
let lastAnnouncementText = '';
let lastAnnouncementAt = 0;
let outputMode = settings.loadOutputMode();
let authMode: 'login' | 'register' = 'login';
let authSessionToken = settings.loadAuthSessionToken();
let authUsername = settings.loadAuthUsername();
let pendingAuthRequest = false;
const messageBuffer: string[] = [];
let messageCursor = -1;
const radioRuntime = new RadioStationRuntime(audio, getItemSpatialConfig);
@@ -482,14 +506,33 @@ function sanitizeName(value: string): string {
return value.replace(/[\u0000-\u001F\u007F<>]/g, '').trim().slice(0, NICKNAME_MAX_LENGTH);
}
/** Normalizes auth username according to server policy. */
function sanitizeAuthUsername(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, '')
.slice(0, 32);
}
/** Enables/disables the connect button based on state and nickname validity. */
function updateConnectAvailability(): void {
dom.logoutButton.disabled = !authSessionToken.trim() && !state.running;
if (state.running) {
dom.connectButton.disabled = true;
dom.loginView.classList.add('hidden');
dom.registerView.classList.add('hidden');
return;
}
const hasNickname = sanitizeName(dom.preconnectNickname.value).length > 0;
dom.connectButton.disabled = mediaSession.isConnecting() || !hasNickname;
dom.loginView.classList.toggle('hidden', authMode !== 'login');
dom.registerView.classList.toggle('hidden', authMode !== 'register');
const hasSessionToken = authSessionToken.trim().length > 0;
const hasLoginCredentials =
sanitizeAuthUsername(dom.authUsername.value).length >= 2 && dom.authPassword.value.trim().length >= 8;
const hasRegisterCredentials =
sanitizeAuthUsername(dom.registerUsername.value).length >= 2 && dom.registerPassword.value.trim().length >= 8;
const authReady = hasSessionToken || (authMode === 'login' ? hasLoginCredentials : hasRegisterCredentials);
dom.connectButton.disabled = mediaSession.isConnecting() || !authReady;
}
/** Restores persisted outbound effect levels from local storage. */
@@ -1294,6 +1337,112 @@ async function reconnectWithRetry(reason: 'heartbeat' | 'socketClose'): Promise<
reconnectInFlight = false;
}
/** Switches pre-connect auth view between login and register modes. */
function setAuthMode(mode: 'login' | 'register'): void {
authMode = mode;
dom.loginView.classList.toggle('hidden', mode !== 'login');
dom.registerView.classList.toggle('hidden', mode !== 'register');
updateConnectAvailability();
}
/** Builds outbound auth packet from local token or active auth form. */
function buildAuthRequestPacket(): OutgoingMessage | null {
const token = authSessionToken.trim();
if (token) {
return { type: 'auth_resume', sessionToken: token };
}
if (authMode === 'register') {
const username = sanitizeAuthUsername(dom.registerUsername.value);
const password = dom.registerPassword.value;
const email = dom.registerEmail.value.trim();
if (!username || !password) return null;
return { type: 'auth_register', username, password, ...(email ? { email } : {}) };
}
const username = sanitizeAuthUsername(dom.authUsername.value);
const password = dom.authPassword.value;
if (!username || !password) return null;
return { type: 'auth_login', username, password };
}
/** Sends current auth request over signaling websocket after socket open. */
function sendAuthRequest(): void {
const packet = buildAuthRequestPacket();
if (!packet) {
updateStatus('Enter username and password.');
audio.sfxUiCancel();
mediaSession.setConnecting(false);
updateConnectAvailability();
signaling.disconnect();
return;
}
pendingAuthRequest = true;
setConnectionStatus('Authenticating...');
signaling.send(packet);
}
/** Handles server auth-required prompts prior to world welcome. */
function handleAuthRequired(message: string): void {
setConnectionStatus('Authentication required.');
updateStatus(message);
}
/** Applies auth result state and terminates failed auth attempts quickly. */
async function handleAuthResult(message: Extract<IncomingMessage, { type: 'auth_result' }>): Promise<void> {
pendingAuthRequest = false;
if (!message.ok) {
dom.authPassword.value = '';
dom.registerPassword.value = '';
if (message.message.toLowerCase().includes('session')) {
authSessionToken = '';
settings.saveAuthSessionToken('');
}
updateStatus(message.message);
audio.sfxUiCancel();
mediaSession.setConnecting(false);
updateConnectAvailability();
signaling.disconnect();
setConnectionStatus('Authentication failed.');
return;
}
if (message.sessionToken) {
authSessionToken = message.sessionToken;
settings.saveAuthSessionToken(message.sessionToken);
}
if (message.username) {
authUsername = message.username;
settings.saveAuthUsername(message.username);
dom.authUsername.value = message.username;
dom.registerUsername.value = message.username;
}
if (message.nickname) {
const resolved = sanitizeName(message.nickname);
if (resolved) {
state.player.nickname = resolved;
dom.preconnectNickname.value = resolved;
settings.saveNickname(resolved);
}
}
dom.authPassword.value = '';
dom.registerPassword.value = '';
setConnectionStatus('Authenticated. Joining world...');
}
/** Clears stored auth session and returns UI to login mode. */
function logOutAccount(): void {
authSessionToken = '';
authUsername = '';
settings.saveAuthSessionToken('');
settings.saveAuthUsername('');
if (state.running) {
signaling.send({ type: 'auth_logout' });
disconnect();
}
setAuthMode('login');
updateStatus('Logged out.');
updateConnectAvailability();
}
/** Builds dependencies shared by connect/disconnect flow helpers. */
function getConnectionFlowDeps(): ConnectFlowDeps {
return {
@@ -1322,6 +1471,7 @@ function getConnectionFlowDeps(): ConnectFlowDeps {
mediaDescribeError: (error) => describeMediaError(error),
mediaStopLocalMedia: () => stopLocalMedia(),
signalingConnect: (handler) => signaling.connect(handler as (message: IncomingMessage) => Promise<void>),
signalingSendAuth: () => sendAuthRequest(),
signalingDisconnect: () => signaling.disconnect(),
onMessage: (message) => onSignalingMessage(message as IncomingMessage),
persistPlayerPosition,
@@ -1423,6 +1573,8 @@ const onAppMessage = createOnMessageHandler({
playIncomingItemUseSound: (url, x, y) => {
void audio.playSpatialSample(url, { x, y }, { x: state.player.x, y: state.player.y }, 1);
},
handleAuthRequired,
handleAuthResult,
});
/** Handles signaling packets with heartbeat/restart metadata before app-level dispatch. */
@@ -2429,20 +2581,51 @@ function setupUiHandlers(): void {
persistPlayerPosition();
},
});
dom.showRegisterButton.addEventListener('click', () => {
setAuthMode('register');
dom.registerUsername.focus();
});
dom.showLoginButton.addEventListener('click', () => {
setAuthMode('login');
dom.authUsername.focus();
});
dom.logoutButton.addEventListener('click', () => {
logOutAccount();
});
dom.authUsername.addEventListener('input', () => {
dom.authUsername.value = sanitizeAuthUsername(dom.authUsername.value);
updateConnectAvailability();
});
dom.authPassword.addEventListener('input', () => {
updateConnectAvailability();
});
dom.registerUsername.addEventListener('input', () => {
dom.registerUsername.value = sanitizeAuthUsername(dom.registerUsername.value);
updateConnectAvailability();
});
dom.registerPassword.addEventListener('input', () => {
updateConnectAvailability();
});
dom.registerEmail.addEventListener('input', () => {
updateConnectAvailability();
});
}
setupInputHandlers();
setupUiHandlers();
dom.authUsername.value = sanitizeAuthUsername(authUsername);
dom.registerUsername.value = sanitizeAuthUsername(authUsername);
const storedNickname = sanitizeName(settings.loadNickname());
dom.preconnectNickname.value = storedNickname;
if (storedNickname) {
state.player.nickname = storedNickname;
}
setAuthMode('login');
updateConnectAvailability();
updateDeviceSummary();
updateStatus(
isVersionReloadedSession()
? 'Client updated, please reconnect.'
: 'Welcome to the Chat Grid. Press the Settings button to configure your audio, then Connect to join the grid.',
: 'Welcome to the Chat Grid. Log in or register, configure audio if needed, then Connect to join the grid.',
);
setConnectionStatus(isVersionReloadedSession() ? 'Client updated, please reconnect.' : 'Not connected.');

View File

@@ -73,6 +73,8 @@ type MessageHandlerDeps = {
playLocateToneAt: (x: number, y: number) => void;
resolveIncomingSoundUrl: (url: string) => string;
playIncomingItemUseSound: (url: string, x: number, y: number) => void;
handleAuthRequired: (message: string) => void;
handleAuthResult: (message: Extract<IncomingMessage, { type: 'auth_result' }>) => Promise<void>;
};
/**
@@ -81,6 +83,14 @@ type MessageHandlerDeps = {
export function createOnMessageHandler(deps: MessageHandlerDeps): (message: IncomingMessage) => Promise<void> {
return async function onMessage(message: IncomingMessage): Promise<void> {
switch (message.type) {
case 'auth_required':
deps.handleAuthRequired(message.message);
break;
case 'auth_result':
await deps.handleAuthResult(message);
break;
case 'welcome':
if (message.worldConfig?.gridSize && Number.isInteger(message.worldConfig.gridSize) && message.worldConfig.gridSize > 0) {
deps.setWorldGridSize(message.worldConfig.gridSize);

View File

@@ -48,6 +48,14 @@ export const welcomeMessageSchema = z.object({
version: z.string().optional(),
})
.optional(),
auth: z
.object({
authenticated: z.boolean(),
userId: z.string().nullable().optional(),
username: z.string().nullable().optional(),
role: z.string().nullable().optional(),
})
.optional(),
uiDefinitions: z
.object({
itemTypeOrder: z.array(z.string().min(1)),
@@ -85,6 +93,21 @@ export const welcomeMessageSchema = z.object({
.optional(),
});
export const authRequiredSchema = z.object({
type: z.literal('auth_required'),
message: z.string(),
});
export const authResultSchema = z.object({
type: z.literal('auth_result'),
ok: z.boolean(),
message: z.string(),
sessionToken: z.string().optional(),
username: z.string().optional(),
role: z.string().optional(),
nickname: z.string().optional(),
});
export const signalMessageSchema = z.object({
type: z.literal('signal'),
senderId: z.string(),
@@ -203,6 +226,8 @@ export const itemPianoStatusSchema = z.object({
});
export const incomingMessageSchema = z.discriminatedUnion('type', [
authRequiredSchema,
authResultSchema,
welcomeMessageSchema,
signalMessageSchema,
updatePositionSchema,
@@ -223,6 +248,10 @@ export const incomingMessageSchema = z.discriminatedUnion('type', [
export type IncomingMessage = z.infer<typeof incomingMessageSchema>;
export type OutgoingMessage =
| { type: 'auth_register'; username: string; password: string; email?: string }
| { type: 'auth_login'; username: string; password: string }
| { type: 'auth_resume'; sessionToken: string }
| { type: 'auth_logout' }
| { type: 'signal'; targetId: string; sdp?: RTCSessionDescriptionInit; ice?: RTCIceCandidateInit }
| { type: 'update_position'; x: number; y: number }
| { type: 'teleport_complete'; x: number; y: number }

View File

@@ -29,6 +29,7 @@ export type ConnectFlowDeps = {
mediaDescribeError: (error: unknown) => string;
mediaStopLocalMedia: () => void;
signalingConnect: (onMessage: (message: unknown) => Promise<void>) => Promise<void>;
signalingSendAuth: () => void;
signalingDisconnect: () => void;
onMessage: (message: unknown) => Promise<void>;
persistPlayerPosition: () => void;
@@ -46,14 +47,11 @@ export async function runConnectFlow(deps: ConnectFlowDeps): Promise<void> {
return;
}
const nickname = deps.sanitizeName(deps.dom.preconnectNickname.value);
if (!nickname) {
deps.updateStatus('Nickname is required.');
deps.updateConnectAvailability();
return;
}
deps.state.player.nickname = nickname;
deps.state.player.nickname = nickname || deps.state.player.nickname;
deps.dom.preconnectNickname.value = nickname;
deps.settingsSaveNickname(nickname);
if (nickname) {
deps.settingsSaveNickname(nickname);
}
deps.mediaSetConnecting(true);
deps.updateConnectAvailability();
@@ -85,6 +83,7 @@ export async function runConnectFlow(deps: ConnectFlowDeps): Promise<void> {
try {
await deps.signalingConnect(deps.onMessage);
deps.signalingSendAuth();
window.setTimeout(() => {
if (deps.state.running || !deps.mediaIsConnecting()) {
return;

View File

@@ -11,6 +11,8 @@ const MIC_INPUT_GAIN_STORAGE_KEY = 'chatGridMicInputGain';
const MASTER_VOLUME_STORAGE_KEY = 'chatGridMasterVolume';
const PEER_LISTEN_GAINS_STORAGE_KEY = 'chatGridPeerListenGains';
const NICKNAME_STORAGE_KEY = 'spatialChatNickname';
const AUTH_SESSION_TOKEN_STORAGE_KEY = 'chatGridAuthSessionToken';
const AUTH_USERNAME_STORAGE_KEY = 'chatGridAuthUsername';
type DevicePreference = {
id: string;
@@ -113,6 +115,30 @@ export class SettingsStore {
localStorage.setItem(NICKNAME_STORAGE_KEY, value);
}
loadAuthSessionToken(): string {
return localStorage.getItem(AUTH_SESSION_TOKEN_STORAGE_KEY) || '';
}
saveAuthSessionToken(token: string): void {
if (token) {
localStorage.setItem(AUTH_SESSION_TOKEN_STORAGE_KEY, token);
return;
}
localStorage.removeItem(AUTH_SESSION_TOKEN_STORAGE_KEY);
}
loadAuthUsername(): string {
return localStorage.getItem(AUTH_USERNAME_STORAGE_KEY) || '';
}
saveAuthUsername(username: string): void {
if (username) {
localStorage.setItem(AUTH_USERNAME_STORAGE_KEY, username);
return;
}
localStorage.removeItem(AUTH_USERNAME_STORAGE_KEY);
}
loadOutputMode(): 'mono' | 'stereo' {
return localStorage.getItem(AUDIO_OUTPUT_MODE_STORAGE_KEY) === 'mono' ? 'mono' : 'stereo';
}

View File

@@ -79,6 +79,44 @@ body {
margin-bottom: 0.75rem;
}
.auth-panel {
width: min(460px, 95vw);
margin: 0 auto 0.75rem;
padding: 0.65rem;
border: 1px solid #334155;
border-radius: 0.6rem;
background: rgb(17 24 39 / 60%);
}
.auth-panel h2 {
margin: 0 0 0.5rem;
font-size: 1rem;
color: #cbd5e1;
}
.auth-row {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin: 0.35rem 0;
}
.auth-row label {
color: #cbd5e1;
min-width: 135px;
text-align: right;
}
.auth-row input {
background: #111827;
color: #e5e7eb;
border: 1px solid #334155;
border-radius: 0.5rem;
padding: 0.4rem 0.6rem;
width: min(280px, 60vw);
}
.nickname-row {
display: flex;
justify-content: center;