Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
@ -1,7 +1 @@
|
|||||||
export interface AuthUser {
|
export type { AuthUser } from "@mancala/core";
|
||||||
// Stable user id used everywhere in the game (player ids, rtmt client ids).
|
|
||||||
id: string;
|
|
||||||
// Display name shown to other players. Undefined for legacy userKey clients.
|
|
||||||
name?: string;
|
|
||||||
isGuest: boolean;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,11 +0,0 @@
|
|||||||
// WebSocket auth protocol. Move to @mancala/core together with the client implementation.
|
|
||||||
|
|
||||||
// client -> server: { token: string } — Keycloak access token. Send right after connecting, and again after refreshing.
|
|
||||||
// Other messages are ignored until on_auth arrives.
|
|
||||||
export const channel_auth = "auth";
|
|
||||||
// server -> client: { user: AuthUser } — the socket is authenticated as this user.
|
|
||||||
export const channel_on_auth = "on_auth";
|
|
||||||
// server -> client: { reason: string } — sent right before the socket is closed with CLOSE_AUTH_FAILED.
|
|
||||||
export const channel_on_auth_error = "on_auth_error";
|
|
||||||
|
|
||||||
export const CLOSE_AUTH_FAILED = 4001;
|
|
||||||
@ -18,8 +18,8 @@ import {
|
|||||||
import { GameMove } from "@mancala/core";
|
import { GameMove } from "@mancala/core";
|
||||||
import { GameCrashManager } from "./GameCrashManager";
|
import { GameCrashManager } from "./GameCrashManager";
|
||||||
import { MatchMaker } from "../matchmaker/MatchMaker";
|
import { MatchMaker } from "../matchmaker/MatchMaker";
|
||||||
import { GameUsersConnectionInfo } from "@mancala/core";
|
import { GameUsersConnectionInfo, UserConnectionInfo } from "@mancala/core";
|
||||||
import { BackendGame, PlayerConnectionInfo, PlayerInfo } from "../models/Game";
|
import { BackendGame, PlayerInfo } from "../models/Game";
|
||||||
|
|
||||||
export class GameManager {
|
export class GameManager {
|
||||||
gameStore: GameStore;
|
gameStore: GameStore;
|
||||||
@ -176,7 +176,7 @@ export class GameManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getGameUsersConnectionInfoFromPlayers(player1: PlayerInfo, player2: PlayerInfo): GameUsersConnectionInfo {
|
private getGameUsersConnectionInfoFromPlayers(player1: PlayerInfo, player2: PlayerInfo): GameUsersConnectionInfo {
|
||||||
const toConnectionInfo = (player: PlayerInfo): PlayerConnectionInfo =>
|
const toConnectionInfo = (player: PlayerInfo): UserConnectionInfo =>
|
||||||
({ ...player, isOnline: this.rtmt.isClientOnline(player.userId) });
|
({ ...player, isOnline: this.rtmt.isClientOnline(player.userId) });
|
||||||
return {
|
return {
|
||||||
user1ConnectionInfo: toConnectionInfo(player1),
|
user1ConnectionInfo: toConnectionInfo(player1),
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Game, UserConnectionInfo } from "@mancala/core";
|
import { Game } from "@mancala/core";
|
||||||
|
|
||||||
export interface PlayerInfo {
|
export interface PlayerInfo {
|
||||||
userId: string;
|
userId: string;
|
||||||
@ -7,9 +7,6 @@ export interface PlayerInfo {
|
|||||||
isGuest: boolean;
|
isGuest: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sent to clients in place of UserConnectionInfo; the extra fields are ignored by older clients.
|
|
||||||
export type PlayerConnectionInfo = UserConnectionInfo & PlayerInfo;
|
|
||||||
|
|
||||||
export interface BackendGame extends Game {
|
export interface BackendGame extends Game {
|
||||||
spectatorIds: string[];
|
spectatorIds: string[];
|
||||||
// Keyed by user id, captured when the game is created.
|
// Keyed by user id, captured when the game is created.
|
||||||
|
|||||||
@ -2,11 +2,10 @@ import { OnClientConnectionChange, OnMessage, RTMT } from "./rtmt"
|
|||||||
|
|
||||||
import WebSocket from "ws"
|
import WebSocket from "ws"
|
||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
import { channel_ping, channel_pong } from "@mancala/core";
|
import { channel_auth, channel_on_auth, channel_on_auth_error, channel_ping, channel_pong, CLOSE_AUTH_FAILED } from "@mancala/core";
|
||||||
import { RTMT_WS_PING_INTERVAL } from "../consts/config";
|
import { RTMT_WS_PING_INTERVAL } from "../consts/config";
|
||||||
import { decode, encode, Message } from "./encode_decode_message";
|
import { decode, encode, Message } from "./encode_decode_message";
|
||||||
import { AuthUser, InvalidTokenError, TokenVerifier } from "../auth";
|
import { AuthUser, InvalidTokenError, TokenVerifier } from "../auth";
|
||||||
import { channel_auth, channel_on_auth, channel_on_auth_error, CLOSE_AUTH_FAILED } from "../auth/channels";
|
|
||||||
|
|
||||||
const DEFAULT_AUTH_TIMEOUT_MS = 5000;
|
const DEFAULT_AUTH_TIMEOUT_MS = 5000;
|
||||||
const LEGACY_USER_KEY_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
const LEGACY_USER_KEY_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import * as http from "http";
|
|||||||
import { AddressInfo } from "net";
|
import { AddressInfo } from "net";
|
||||||
import WebSocket from "ws";
|
import WebSocket from "ws";
|
||||||
import { AuthUser, InvalidTokenError, TokenVerifier } from "../../src/auth";
|
import { AuthUser, InvalidTokenError, TokenVerifier } from "../../src/auth";
|
||||||
import { channel_auth, channel_on_auth, channel_on_auth_error, CLOSE_AUTH_FAILED } from "../../src/auth/channels";
|
import { channel_auth, channel_on_auth, channel_on_auth_error, CLOSE_AUTH_FAILED } from "@mancala/core";
|
||||||
import { RTMTWS } from "../../src/rtmt/rtmt_websocket";
|
import { RTMTWS } from "../../src/rtmt/rtmt_websocket";
|
||||||
|
|
||||||
const LEGACY_KEY = "0b9f6d1e-7c1a-4f7e-9a51-3f0c2b6d8e11";
|
const LEGACY_KEY = "0b9f6d1e-7c1a-4f7e-9a51-3f0c2b6d8e11";
|
||||||
|
|||||||
@ -18,6 +18,8 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: apps/frontend/Dockerfile
|
dockerfile: apps/frontend/Dockerfile
|
||||||
|
args:
|
||||||
|
KEYCLOAK_URL: ${KEYCLOAK_HOSTNAME:-http://localhost:8080}
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
keycloak-db:
|
keycloak-db:
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# Build from the repo root: docker build -f apps/frontend/Dockerfile .
|
# Build from the repo root: docker build -f apps/frontend/Dockerfile .
|
||||||
FROM node:20-alpine3.19 AS build
|
FROM node:20-alpine3.19 AS build
|
||||||
|
|
||||||
ARG SERVER_ADDRESS WS_SERVER_ADDRESS
|
ARG SERVER_ADDRESS WS_SERVER_ADDRESS KEYCLOAK_URL
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@ -18,10 +18,24 @@ yarn dev
|
|||||||
|
|
||||||
### Development Dependencies
|
### Development Dependencies
|
||||||
|
|
||||||
This package depends on the local packages `@mancala/core` and `mancala.js`. Refer to their documentation for further instructions.
|
This package links the local packages `@mancala/core` and `mancala.js`. Build them first with `yarn build:packages` in the repo root.
|
||||||
|
|
||||||
|
### Login
|
||||||
|
|
||||||
|
Every visitor has a Keycloak session (see `apps/keycloak`): the first visit logs in as a new guest without any
|
||||||
|
form, and the account menu on the home page lets guests log in or sign up. Run Keycloak locally with
|
||||||
|
`docker compose up keycloak` in `apps/`; the dev server must run on port 1234, which the `mancala-web` client allows.
|
||||||
|
|
||||||
|
| Build variable | Default |
|
||||||
|
|---|---|
|
||||||
|
| `SERVER_ADDRESS` | `http://localhost:5005` |
|
||||||
|
| `WS_SERVER_ADDRESS` | `ws://localhost:5005` |
|
||||||
|
| `KEYCLOAK_URL` | `http://localhost:8080` |
|
||||||
|
|
||||||
### Docker Build
|
### Docker Build
|
||||||
|
|
||||||
|
From the repo root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build --build-arg SERVER_ADDRESS=http://localhost:5000 --build-arg WS_SERVER_ADDRESS=ws://localhost:5000 -t mancala-frontend .
|
docker build -f apps/frontend/Dockerfile --build-arg SERVER_ADDRESS=http://localhost:5000 --build-arg WS_SERVER_ADDRESS=ws://localhost:5000 --build-arg KEYCLOAK_URL=http://localhost:8080 -t mancala-frontend .
|
||||||
```
|
```
|
||||||
@ -23,6 +23,7 @@
|
|||||||
"i18next": "^23.11.5",
|
"i18next": "^23.11.5",
|
||||||
"mancala.js": "link:../../packages/mancala.js",
|
"mancala.js": "link:../../packages/mancala.js",
|
||||||
"notyf": "^3.10.0",
|
"notyf": "^3.10.0",
|
||||||
|
"oidc-client-ts": "3.5.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-i18next": "^14.1.2",
|
"react-i18next": "^14.1.2",
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import GamePage from './routes/GamePage';
|
|||||||
import Home from './routes/Home';
|
import Home from './routes/Home';
|
||||||
|
|
||||||
import { initContext } from './context/context';
|
import { initContext } from './context/context';
|
||||||
import { RTMTWS, ConnectionState } from '@mancala/core';
|
import { RTMTWS, ConnectionState, AuthUser } from '@mancala/core';
|
||||||
import { getColorByBrightness } from "@mancala/core";
|
import { getColorByBrightness } from "@mancala/core";
|
||||||
import { Theme } from '@mancala/core';
|
import { Theme } from '@mancala/core';
|
||||||
import LobyPage from './routes/LobyPage';
|
import LobyPage from './routes/LobyPage';
|
||||||
@ -26,7 +26,13 @@ const MancalaApp: FunctionComponent = () => {
|
|||||||
|
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
const [userKey, setUserKey] = useState<string | undefined>(undefined);
|
// Set once the server has authenticated the connection.
|
||||||
|
const [authUser, setAuthUser] = useState<AuthUser | undefined>(undefined);
|
||||||
|
|
||||||
|
// Pages compare this with the game's player ids.
|
||||||
|
const userKey = authUser?.id;
|
||||||
|
|
||||||
|
const [hasSession, setHasSession] = useState<boolean>(false);
|
||||||
|
|
||||||
const [connectionState, setConnetionState] = useState<ConnectionState>("connecting");
|
const [connectionState, setConnetionState] = useState<ConnectionState>("connecting");
|
||||||
|
|
||||||
@ -44,35 +50,66 @@ const MancalaApp: FunctionComponent = () => {
|
|||||||
i18n.changeLanguage(deviceLanguage === "tr-TR" ? "tr" : "en")
|
i18n.changeLanguage(deviceLanguage === "tr-TR" ? "tr" : "en")
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const connectRTMT = (userKey: string) => {
|
const onAuthenticated = (user: AuthUser) => setAuthUser(user);
|
||||||
|
|
||||||
|
// If the session can no longer be refreshed, start over as a new guest.
|
||||||
|
const getAccessTokenOrLoginAgain = async () => {
|
||||||
|
try {
|
||||||
|
return await context.authService.getAccessToken();
|
||||||
|
} catch (error) {
|
||||||
|
context.authService.loginAsGuest();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectRTMT = () => {
|
||||||
const rtmt = context.rtmt as RTMTWS;
|
const rtmt = context.rtmt as RTMTWS;
|
||||||
rtmt.on("error", onConnectionError);
|
rtmt.on("error", onConnectionError);
|
||||||
rtmt.on("connectionchange", onConnectionChange)
|
rtmt.on("connectionchange", onConnectionChange);
|
||||||
const url = server.wsServerAdress + "?userKey=" + userKey;
|
rtmt.on("authenticated", onAuthenticated);
|
||||||
rtmt.connectWebSocket(url, userKey)
|
rtmt.connectWebSocket(server.wsServerAdress, getAccessTokenOrLoginAgain);
|
||||||
return rtmt;
|
return rtmt;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadUserKeyAndConnectServer = () => {
|
const startSession = async () => {
|
||||||
context.userKeyStore.getUserKey().then((userKey: string) => {
|
const authService = context.authService;
|
||||||
setUserKey(userKey);
|
if (authService.isCallback()) {
|
||||||
connectRTMT(userKey);
|
let returnTo = "/";
|
||||||
}).catch((error) => {
|
try {
|
||||||
//TODO: check if it is network error!
|
returnTo = await authService.handleCallback();
|
||||||
swal(t("Error") + "!", t("ErrorWhenRetrievingInformation"), "error");
|
} catch (error) {
|
||||||
console.error(error);
|
// e.g. the callback page was reloaded; fall back to the stored session.
|
||||||
});
|
console.error("(Auth) Login callback failed", error);
|
||||||
|
}
|
||||||
|
window.history.replaceState(null, "", returnTo);
|
||||||
|
}
|
||||||
|
const user = await authService.getUser();
|
||||||
|
if (!user) {
|
||||||
|
await authService.loginAsGuest();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHasSession(true);
|
||||||
|
connectRTMT();
|
||||||
}
|
}
|
||||||
|
|
||||||
const disposeApp = () => {
|
const disposeApp = () => {
|
||||||
context.rtmt?.dispose();
|
context.rtmt?.dispose();
|
||||||
|
context.rtmt.off("authenticated", onAuthenticated);
|
||||||
context.themeManager.off("themechange", onThemeChange);
|
context.themeManager.off("themechange", onThemeChange);
|
||||||
}
|
}
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
loadUserKeyAndConnectServer();
|
startSession().catch((error) => {
|
||||||
|
swal(t("Error") + "!", t("ErrorWhenRetrievingInformation"), "error");
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
const removeUserLoadedListener = context.authService.onUserLoaded(
|
||||||
|
(user) => (context.rtmt as RTMTWS).reauthenticate(user.access_token));
|
||||||
context.themeManager.on("themechange", onThemeChange);
|
context.themeManager.on("themechange", onThemeChange);
|
||||||
return () => disposeApp();
|
return () => {
|
||||||
|
removeUserLoadedListener();
|
||||||
|
disposeApp();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const textColorOnBoard = getColorByBrightness(
|
const textColorOnBoard = getColorByBrightness(
|
||||||
@ -82,9 +119,9 @@ const MancalaApp: FunctionComponent = () => {
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<BrowserRouter>
|
{hasSession && <BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<Home context={context} theme={theme} userKey={userKey} />} />
|
<Route index element={<Home context={context} theme={theme} userKey={userKey} authUser={authUser} />} />
|
||||||
<Route path="/" >
|
<Route path="/" >
|
||||||
<Route path="game" >
|
<Route path="game" >
|
||||||
<Route path=":gameId" element={<GamePage context={context} theme={theme} userKey={userKey} />} ></Route>
|
<Route path=":gameId" element={<GamePage context={context} theme={theme} userKey={userKey} />} ></Route>
|
||||||
@ -95,7 +132,7 @@ const MancalaApp: FunctionComponent = () => {
|
|||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>}
|
||||||
<FloatingPanel context={context} color={context.themeManager.theme.boardColor} visible={connectionState != "connected"}>
|
<FloatingPanel context={context} color={context.themeManager.theme.boardColor} visible={connectionState != "connected"}>
|
||||||
<span style={{ color: textColorOnBoard, transition: 'color 0.5s' }}>{Util.getTextByConnectionState(context, connectionState, t)}</span>
|
<span style={{ color: textColorOnBoard, transition: 'color 0.5s' }}>{Util.getTextByConnectionState(context, connectionState, t)}</span>
|
||||||
</FloatingPanel>
|
</FloatingPanel>
|
||||||
|
|||||||
85
apps/frontend/src/auth/AuthService.ts
Normal file
85
apps/frontend/src/auth/AuthService.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { User, UserManager, WebStorageStateStore } from "oidc-client-ts";
|
||||||
|
|
||||||
|
const CALLBACK_PATH = "/auth/callback";
|
||||||
|
|
||||||
|
type SigninState = { returnTo: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keycloak login for the web app. Everyone has a session: visitors without one are logged in as a new guest,
|
||||||
|
* and guests can log in or sign up to switch to an account.
|
||||||
|
*/
|
||||||
|
export class AuthService {
|
||||||
|
private readonly userManager: UserManager;
|
||||||
|
|
||||||
|
constructor(params: { keycloakUrl: string, realm: string, clientId: string }) {
|
||||||
|
const origin = window.location.origin;
|
||||||
|
this.userManager = new UserManager({
|
||||||
|
authority: `${params.keycloakUrl}/realms/${params.realm}`,
|
||||||
|
client_id: params.clientId,
|
||||||
|
redirect_uri: origin + CALLBACK_PATH,
|
||||||
|
post_logout_redirect_uri: origin + "/",
|
||||||
|
response_type: "code",
|
||||||
|
// offline_access keeps guests' identity after the SSO session ends.
|
||||||
|
scope: "openid offline_access",
|
||||||
|
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
||||||
|
// Renews with the refresh token shortly before the access token expires.
|
||||||
|
automaticSilentRenew: true,
|
||||||
|
monitorSession: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public isCallback(): boolean {
|
||||||
|
return window.location.pathname === CALLBACK_PATH;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Completes a login redirect and returns the path the user started from.
|
||||||
|
public async handleCallback(): Promise<string> {
|
||||||
|
const user = await this.userManager.signinRedirectCallback();
|
||||||
|
return (user.state as SigninState | undefined)?.returnTo ?? "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stored session, refreshed if its access token has expired. Null if there is no usable session.
|
||||||
|
public async getUser(): Promise<User | null> {
|
||||||
|
const user = await this.userManager.getUser();
|
||||||
|
if (!user || !user.expired) return user;
|
||||||
|
try {
|
||||||
|
return await this.userManager.signinSilent();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("(Auth) Could not refresh the session", error);
|
||||||
|
await this.userManager.removeUser();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAccessToken(): Promise<string> {
|
||||||
|
const user = await this.getUser();
|
||||||
|
if (!user) throw new Error("Not logged in");
|
||||||
|
return user.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public onUserLoaded(callback: (user: User) => void): () => void {
|
||||||
|
this.userManager.events.addUserLoaded(callback);
|
||||||
|
return () => this.userManager.events.removeUserLoaded(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
public loginAsGuest(returnTo = currentPath()): Promise<void> {
|
||||||
|
return this.userManager.signinRedirect({ extraQueryParams: { guest: "true" }, state: { returnTo } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// prompt=login shows the login form even though the guest already has a session.
|
||||||
|
public login(returnTo = currentPath()): Promise<void> {
|
||||||
|
return this.userManager.signinRedirect({ prompt: "login", state: { returnTo } });
|
||||||
|
}
|
||||||
|
|
||||||
|
public signUp(returnTo = currentPath()): Promise<void> {
|
||||||
|
return this.userManager.signinRedirect({ prompt: "create", state: { returnTo } });
|
||||||
|
}
|
||||||
|
|
||||||
|
public logout(): Promise<void> {
|
||||||
|
return this.userManager.signoutRedirect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentPath(): string {
|
||||||
|
return window.location.pathname + window.location.search;
|
||||||
|
}
|
||||||
50
apps/frontend/src/components/headerbar/AccountMenu.tsx
Normal file
50
apps/frontend/src/components/headerbar/AccountMenu.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { Menu, MenuHeader, MenuItem } from "@szhsin/react-menu";
|
||||||
|
import * as React from "react";
|
||||||
|
import { FunctionComponent } from "react";
|
||||||
|
import { AuthUser, getPlayerDisplayName } from "@mancala/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Context } from "../../context/context";
|
||||||
|
import "@szhsin/react-menu/dist/index.css";
|
||||||
|
import "@szhsin/react-menu/dist/transitions/slide.css"
|
||||||
|
|
||||||
|
// Guests can log in or sign up; logged in users can log out.
|
||||||
|
const AccountMenu: FunctionComponent<{ context: Context, textColor: string, authUser?: AuthUser }> = (props) => {
|
||||||
|
const { context, textColor, authUser } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (!authUser) return <></>;
|
||||||
|
|
||||||
|
const authService = context.authService;
|
||||||
|
const menuButton = <span
|
||||||
|
style={{ color: textColor, cursor: 'pointer', userSelect: 'none' }}
|
||||||
|
className="material-symbols-outlined">
|
||||||
|
account_circle
|
||||||
|
</span>;
|
||||||
|
const itemStyle = { color: textColor };
|
||||||
|
const menuItems = authUser.isGuest ? [
|
||||||
|
<MenuItem key="login" style={itemStyle} onClick={() => authService.login()}>{t("LogIn")}</MenuItem>,
|
||||||
|
<MenuItem key="signup" style={itemStyle} onClick={() => authService.signUp()}>{t("SignUp")}</MenuItem>,
|
||||||
|
] : [
|
||||||
|
<MenuItem key="logout" style={itemStyle} onClick={() => authService.logout()}>{t("LogOut")}</MenuItem>,
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="menu-container">
|
||||||
|
<Menu
|
||||||
|
menuStyle={{ background: context.themeManager.theme.appBarBgColor }}
|
||||||
|
menuButton={menuButton}
|
||||||
|
transition
|
||||||
|
align="end">
|
||||||
|
<MenuHeader style={itemStyle}>{getPlayerDisplayName(authUser, t)}</MenuHeader>
|
||||||
|
{menuItems}
|
||||||
|
</Menu>
|
||||||
|
<style jsx>{`
|
||||||
|
.menu-container {
|
||||||
|
margin: 0 1vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AccountMenu;
|
||||||
@ -15,4 +15,13 @@ export const server: Server = {
|
|||||||
wsServerAdress: process.env.WS_SERVER_ADDRESS ? `${process.env.WS_SERVER_ADDRESS}` : LOCAL_WS_SERVER_ADDRESS,
|
wsServerAdress: process.env.WS_SERVER_ADDRESS ? `${process.env.WS_SERVER_ADDRESS}` : LOCAL_WS_SERVER_ADDRESS,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const LOCAL_KEYCLOAK_URL = 'http://localhost:8080';
|
||||||
|
|
||||||
|
export const keycloak = {
|
||||||
|
//@ts-ignore
|
||||||
|
url: process.env.KEYCLOAK_URL ? `${process.env.KEYCLOAK_URL}` : LOCAL_KEYCLOAK_URL,
|
||||||
|
realm: "mancala",
|
||||||
|
clientId: "mancala-web",
|
||||||
|
};
|
||||||
|
|
||||||
export const RTMT_WS_PING_INTERVAL = 1000, RTMT_WS_PING_INTERVAL_BUFFER_TIME = 2000;
|
export const RTMT_WS_PING_INTERVAL = 1000, RTMT_WS_PING_INTERVAL_BUFFER_TIME = 2000;
|
||||||
@ -1,14 +1,14 @@
|
|||||||
import { server } from "../const/config";
|
import { keycloak, server } from "../const/config";
|
||||||
import { RTMT, RTMTWS, ThemeManager } from "@mancala/core";
|
import { RTMT, RTMTWS, ThemeManager } from "@mancala/core";
|
||||||
import { HttpServiceImpl } from "../service/HttpService";
|
import { HttpServiceImpl } from "../service/HttpService";
|
||||||
import { GameStore, GameStoreImpl } from "../store/GameStore";
|
import { GameStore, GameStoreImpl } from "../store/GameStore";
|
||||||
import { UserKeyStore, UserKeyStoreImpl } from "../store/KeyStore";
|
import { AuthService } from "../auth/AuthService";
|
||||||
import { LocalStorage } from "../storage";
|
import { LocalStorage } from "../storage";
|
||||||
import { Storage } from '@mancala/core'
|
import { Storage } from '@mancala/core'
|
||||||
|
|
||||||
export type Context = {
|
export type Context = {
|
||||||
rtmt: RTMT;
|
rtmt: RTMT;
|
||||||
userKeyStore: UserKeyStore;
|
authService: AuthService;
|
||||||
themeManager: ThemeManager;
|
themeManager: ThemeManager;
|
||||||
gameStore: GameStore;
|
gameStore: GameStore;
|
||||||
storage: Storage;
|
storage: Storage;
|
||||||
@ -17,13 +17,13 @@ export type Context = {
|
|||||||
export const initContext = (): Context => {
|
export const initContext = (): Context => {
|
||||||
const rtmt = new RTMTWS();
|
const rtmt = new RTMTWS();
|
||||||
const httpService = new HttpServiceImpl(server.serverAdress);
|
const httpService = new HttpServiceImpl(server.serverAdress);
|
||||||
const userKeyStore = new UserKeyStoreImpl({ httpService });
|
const authService = new AuthService({ keycloakUrl: keycloak.url, realm: keycloak.realm, clientId: keycloak.clientId });
|
||||||
const gameStore = new GameStoreImpl({ httpService });
|
const gameStore = new GameStoreImpl({ httpService });
|
||||||
const storage = new LocalStorage();
|
const storage = new LocalStorage();
|
||||||
const themeManager = new ThemeManager(storage);
|
const themeManager = new ThemeManager(storage);
|
||||||
return {
|
return {
|
||||||
rtmt: rtmt,
|
rtmt: rtmt,
|
||||||
userKeyStore: userKeyStore,
|
authService,
|
||||||
themeManager,
|
themeManager,
|
||||||
gameStore,
|
gameStore,
|
||||||
storage
|
storage
|
||||||
|
|||||||
@ -22,7 +22,7 @@ import { Context } from '../context/context';
|
|||||||
import useWindowDimensions from '../hooks/useWindowDimensions';
|
import useWindowDimensions from '../hooks/useWindowDimensions';
|
||||||
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
|
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } from "@mancala/core";
|
||||||
import { Theme } from '@mancala/core';
|
import { Theme } from '@mancala/core';
|
||||||
import { getColorByBrightness } from "@mancala/core";
|
import { getColorByBrightness, getPlayerDisplayName } from "@mancala/core";
|
||||||
import { BoardViewModel } from "@mancala/core";
|
import { BoardViewModel } from "@mancala/core";
|
||||||
import Center from '../components/Center';
|
import Center from '../components/Center';
|
||||||
import notyf from '../util/Notyf';
|
import notyf from '../util/Notyf';
|
||||||
@ -84,6 +84,11 @@ const GamePage: FunctionComponent<{
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getPlayerName = (userId: string) => {
|
||||||
|
const connectionInfos = [gameUsersConnectionInfo?.user1ConnectionInfo, gameUsersConnectionInfo?.user2ConnectionInfo];
|
||||||
|
return getPlayerDisplayName(connectionInfos.find((info) => info?.userId === userId), t);
|
||||||
|
}
|
||||||
|
|
||||||
const onGameUpdateEvent = (pitAnimator: PitAnimator, message: Object) => {
|
const onGameUpdateEvent = (pitAnimator: PitAnimator, message: Object) => {
|
||||||
const newGame: Game = message as Game;
|
const newGame: Game = message as Game;
|
||||||
newGame.mancalaGame = MancalaGame.createFromMancalaGame(newGame.mancalaGame);
|
newGame.mancalaGame = MancalaGame.createFromMancalaGame(newGame.mancalaGame);
|
||||||
@ -230,17 +235,19 @@ const GamePage: FunctionComponent<{
|
|||||||
const showBoardView = game && boardViewModel && userKey && true;
|
const showBoardView = game && boardViewModel && userKey && true;
|
||||||
const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0";
|
const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0";
|
||||||
const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1";
|
const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1";
|
||||||
|
const topLocatedUserName = getPlayerName(topLocatedUserId);
|
||||||
|
const bottomLocatedUserName = getPlayerName(bottomLocatedUserId);
|
||||||
const topLocatedUser = {
|
const topLocatedUser = {
|
||||||
id: topLocatedUserId,
|
id: topLocatedUserId,
|
||||||
name: "Anonymous",
|
name: topLocatedUserName ?? "Anonymous",
|
||||||
isOnline: isUserOnline(topLocatedUserId),
|
isOnline: isUserOnline(topLocatedUserId),
|
||||||
isAnonymous: true
|
isAnonymous: !topLocatedUserName
|
||||||
};
|
};
|
||||||
const bottomLocatedUser = {
|
const bottomLocatedUser = {
|
||||||
id: bottomLocatedUserId,
|
id: bottomLocatedUserId,
|
||||||
name: "Anonymous",
|
name: bottomLocatedUserName ?? "Anonymous",
|
||||||
isOnline: isSpectator ? isUserOnline(bottomLocatedUserId) : context.rtmt.connectionState === "connected",
|
isOnline: isSpectator ? isUserOnline(bottomLocatedUserId) : context.rtmt.connectionState === "connected",
|
||||||
isAnonymous: true
|
isAnonymous: !bottomLocatedUserName
|
||||||
};
|
};
|
||||||
const currentUser = isSpectator ? {
|
const currentUser = isSpectator ? {
|
||||||
id: "2",
|
id: "2",
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { FunctionComponent, useEffect, useState } from "react";
|
import { FunctionComponent, useEffect, useState } from "react";
|
||||||
import { getColorByBrightness } from "@mancala/core";
|
import { getColorByBrightness } from "@mancala/core";
|
||||||
import { Theme } from "@mancala/core";
|
import { AuthUser, Theme } from "@mancala/core";
|
||||||
import HeaderBar from "../components/headerbar/HeaderBar";
|
import HeaderBar from "../components/headerbar/HeaderBar";
|
||||||
import PageContainer from "../components/PageContainer";
|
import PageContainer from "../components/PageContainer";
|
||||||
import Row from "../components/Row";
|
import Row from "../components/Row";
|
||||||
import HeaderbarIcon from "../components/headerbar/HeaderbarIcon";
|
import HeaderbarIcon from "../components/headerbar/HeaderbarIcon";
|
||||||
import HeaderbarTitle from "../components/headerbar/HeaderbarTitle";
|
import HeaderbarTitle from "../components/headerbar/HeaderbarTitle";
|
||||||
import ThemeSwitchMenu from "../components/headerbar/ThemeSwitchMenu";
|
import ThemeSwitchMenu from "../components/headerbar/ThemeSwitchMenu";
|
||||||
|
import AccountMenu from "../components/headerbar/AccountMenu";
|
||||||
import Button from "../components/Button";
|
import Button from "../components/Button";
|
||||||
import { Context } from "../context/context";
|
import { Context } from "../context/context";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
@ -17,8 +18,9 @@ import { useTranslation } from "react-i18next";
|
|||||||
const Home: FunctionComponent<{
|
const Home: FunctionComponent<{
|
||||||
context: Context,
|
context: Context,
|
||||||
userKey?: string,
|
userKey?: string,
|
||||||
|
authUser?: AuthUser,
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
}> = ({ context, userKey, theme }) => {
|
}> = ({ context, userKey, authUser, theme }) => {
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@ -47,6 +49,7 @@ const Home: FunctionComponent<{
|
|||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row>
|
||||||
<ThemeSwitchMenu context={context} textColor={textColorOnAppBar} />
|
<ThemeSwitchMenu context={context} textColor={textColorOnAppBar} />
|
||||||
|
<AccountMenu context={context} textColor={textColorOnAppBar} authUser={authUser} />
|
||||||
<Button
|
<Button
|
||||||
context={context}
|
context={context}
|
||||||
color={context.themeManager.theme.pitColor}
|
color={context.themeManager.theme.pitColor}
|
||||||
|
|||||||
@ -1,53 +0,0 @@
|
|||||||
import { HttpService } from "../service/HttpService"
|
|
||||||
|
|
||||||
const user_key = "user_key"
|
|
||||||
|
|
||||||
export interface UserKeyStore {
|
|
||||||
getUserKey: () => Promise<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UserKeyStoreImpl implements UserKeyStore {
|
|
||||||
private httpService: HttpService;
|
|
||||||
private keyStoreHttp: UserKeyStore;
|
|
||||||
private keyStoreLocalStorage = new UserKeyStoreLocalStorage()
|
|
||||||
|
|
||||||
constructor(props: { httpService: HttpService }) {
|
|
||||||
this.httpService = props.httpService;
|
|
||||||
this.keyStoreHttp = new UserKeyStoreLocalHttp({ httpService: this.httpService });
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getUserKey(): Promise<string> {
|
|
||||||
const maybeUserKey = await this.keyStoreLocalStorage.getUserKey();
|
|
||||||
if (maybeUserKey === undefined) {
|
|
||||||
const userKey = await this.keyStoreHttp.getUserKey()
|
|
||||||
this.keyStoreLocalStorage.storeUserKey(userKey)
|
|
||||||
return Promise.resolve(userKey);
|
|
||||||
} else {
|
|
||||||
return Promise.resolve(maybeUserKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UserKeyStoreLocalHttp implements UserKeyStore {
|
|
||||||
httpService: HttpService;
|
|
||||||
|
|
||||||
constructor(params: { httpService: HttpService }) {
|
|
||||||
this.httpService = params.httpService;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getUserKey(): Promise<string> {
|
|
||||||
const response = await this.httpService.get("/register/")
|
|
||||||
return response.text();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UserKeyStoreLocalStorage {
|
|
||||||
public getUserKey(): Promise<string | undefined> {
|
|
||||||
const userKey = localStorage.getItem(user_key)
|
|
||||||
return Promise.resolve(userKey === null ? undefined : userKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
public storeUserKey(userKey: string): void {
|
|
||||||
localStorage.setItem(user_key, userKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1564,6 +1564,11 @@ json5@^2.2.0, json5@^2.2.1:
|
|||||||
resolved "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz"
|
resolved "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz"
|
||||||
integrity sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==
|
integrity sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==
|
||||||
|
|
||||||
|
jwt-decode@^4.0.0:
|
||||||
|
version "4.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-4.0.0.tgz#2270352425fd413785b2faf11f6e755c5151bd4b"
|
||||||
|
integrity sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==
|
||||||
|
|
||||||
lines-and-columns@^1.1.6:
|
lines-and-columns@^1.1.6:
|
||||||
version "1.2.4"
|
version "1.2.4"
|
||||||
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
|
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
|
||||||
@ -1686,6 +1691,13 @@ object-assign@^4.1.1:
|
|||||||
resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz"
|
resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz"
|
||||||
integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
|
integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
|
||||||
|
|
||||||
|
oidc-client-ts@3.5.0:
|
||||||
|
version "3.5.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/oidc-client-ts/-/oidc-client-ts-3.5.0.tgz#222c145d35e8d654ea4e1dee71cc5dde5e1c91a2"
|
||||||
|
integrity sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==
|
||||||
|
dependencies:
|
||||||
|
jwt-decode "^4.0.0"
|
||||||
|
|
||||||
ordered-binary@^1.2.4:
|
ordered-binary@^1.2.4:
|
||||||
version "1.3.0"
|
version "1.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/ordered-binary/-/ordered-binary-1.3.0.tgz#a116d64c923278216e335602d279750b2ebd746e"
|
resolved "https://registry.yarnpkg.com/ordered-binary/-/ordered-binary-1.3.0.tgz#a116d64c923278216e335602d279750b2ebd746e"
|
||||||
|
|||||||
@ -58,7 +58,8 @@ function App() {
|
|||||||
rtmt.on("error", onConnectionError);
|
rtmt.on("error", onConnectionError);
|
||||||
rtmt.on("connectionchange", onConnectionChange)
|
rtmt.on("connectionchange", onConnectionChange)
|
||||||
const url = server.wsServerAdress + "?userKey=" + userKey;
|
const url = server.wsServerAdress + "?userKey=" + userKey;
|
||||||
rtmt.connectWebSocket(url, userKey)
|
// Legacy userKey login; mobile moves to Keycloak tokens later.
|
||||||
|
rtmt.connectWebSocket(url)
|
||||||
return rtmt;
|
return rtmt;
|
||||||
}
|
}
|
||||||
const getTextByConnectionState = (context: Context, connectionState: ConnectionState): string => {
|
const getTextByConnectionState = (context: Context, connectionState: ConnectionState): string => {
|
||||||
|
|||||||
@ -38,5 +38,9 @@ export const en = {
|
|||||||
WebApp: "Goto WebApp",
|
WebApp: "Goto WebApp",
|
||||||
Privacy: "Open Privacy Policy",
|
Privacy: "Open Privacy Policy",
|
||||||
Home: "Home",
|
Home: "Home",
|
||||||
RejoinGame: "Rejoin Game"
|
RejoinGame: "Rejoin Game",
|
||||||
|
Guest: "Guest",
|
||||||
|
LogIn: "Log In",
|
||||||
|
SignUp: "Sign Up",
|
||||||
|
LogOut: "Log Out"
|
||||||
}
|
}
|
||||||
@ -38,5 +38,9 @@ export const tr = {
|
|||||||
WebApp: "Web Uygulamasına Git",
|
WebApp: "Web Uygulamasına Git",
|
||||||
Privacy: "Gizlilik Politikasını Göster",
|
Privacy: "Gizlilik Politikasını Göster",
|
||||||
Home: "Ana Sayfa",
|
Home: "Ana Sayfa",
|
||||||
RejoinGame: "Oyuna Geri Dön"
|
RejoinGame: "Oyuna Geri Dön",
|
||||||
|
Guest: "Misafir",
|
||||||
|
LogIn: "Giriş Yap",
|
||||||
|
SignUp: "Kayıt Ol",
|
||||||
|
LogOut: "Çıkış Yap"
|
||||||
};
|
};
|
||||||
7
packages/core/src/models/AuthUser.ts
Normal file
7
packages/core/src/models/AuthUser.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export interface AuthUser {
|
||||||
|
// Stable user id, also used as player id in games.
|
||||||
|
id: string;
|
||||||
|
// Undefined for legacy userKey clients.
|
||||||
|
name?: string;
|
||||||
|
isGuest: boolean;
|
||||||
|
}
|
||||||
@ -1,4 +1,7 @@
|
|||||||
export interface UserConnectionInfo {
|
export interface UserConnectionInfo {
|
||||||
userId: string;
|
userId: string;
|
||||||
isOnline: boolean;
|
isOnline: boolean;
|
||||||
}
|
// Sent by servers with Keycloak auth; undefined for legacy userKey players.
|
||||||
|
name?: string;
|
||||||
|
isGuest?: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@ -2,4 +2,5 @@ export * from './Game'
|
|||||||
export * from './GameMove'
|
export * from './GameMove'
|
||||||
export * from './LoadingState'
|
export * from './LoadingState'
|
||||||
export * from './User'
|
export * from './User'
|
||||||
export * from './UserConnectionInfo'
|
export * from './UserConnectionInfo'
|
||||||
|
export * from './AuthUser'
|
||||||
@ -11,4 +11,11 @@ export const channel_ping = "ping"
|
|||||||
export const channel_pong = "pong"
|
export const channel_pong = "pong"
|
||||||
export const channel_on_user_connection_change = "channel_on_user_connection_change"
|
export const channel_on_user_connection_change = "channel_on_user_connection_change"
|
||||||
export const channel_listen_game_events = "channel_listen_game_events"
|
export const channel_listen_game_events = "channel_listen_game_events"
|
||||||
export const channel_unlisten_game_events = "channel_unlisten_game_events"
|
export const channel_unlisten_game_events = "channel_unlisten_game_events"
|
||||||
|
// Auth handshake. client -> server: { token } right after connecting and again after a token refresh;
|
||||||
|
// other messages are ignored until the server answers on_auth with { user }.
|
||||||
|
export const channel_auth = "auth"
|
||||||
|
export const channel_on_auth = "on_auth"
|
||||||
|
// server -> client: { reason }, sent right before the server closes the socket with CLOSE_AUTH_FAILED.
|
||||||
|
export const channel_on_auth_error = "on_auth_error"
|
||||||
|
export const CLOSE_AUTH_FAILED = 4001
|
||||||
|
|||||||
@ -1,19 +1,25 @@
|
|||||||
import { TinyEmitter } from "tiny-emitter"
|
import { TinyEmitter } from "tiny-emitter"
|
||||||
|
import { AuthUser } from "../../models/AuthUser";
|
||||||
|
|
||||||
export type OnMessage = (message: Object) => any
|
export type OnMessage = (message: Object) => any
|
||||||
|
|
||||||
export type ConnectionState = "none" | "connecting" | "error" | "connected" | "closed" | "reconnecting";
|
export type ConnectionState = "none" | "connecting" | "error" | "connected" | "closed" | "reconnecting";
|
||||||
|
|
||||||
export type RtmtEventTypes = "open" | "close" | "connected" | "error" | "disconnected" | "message" | "connectionchange";
|
export type RtmtEventTypes = "open" | "close" | "connected" | "error" | "disconnected" | "message" | "connectionchange" | "authenticated";
|
||||||
|
|
||||||
export const RTMT_WS_PING_INTERVAL = 1000, RTMT_WS_PING_INTERVAL_BUFFER_TIME = 2000;
|
export const RTMT_WS_PING_INTERVAL = 1000, RTMT_WS_PING_INTERVAL_BUFFER_TIME = 2000;
|
||||||
|
|
||||||
|
// Supplies a current access token; called on every (re)connect.
|
||||||
|
export type TokenProvider = () => Promise<string>;
|
||||||
|
|
||||||
export interface RTMT extends TinyEmitter {
|
export interface RTMT extends TinyEmitter {
|
||||||
get connectionState(): ConnectionState;
|
get connectionState(): ConnectionState;
|
||||||
|
// The user the server authenticated this connection as, once connected with a token.
|
||||||
|
get user(): AuthUser | undefined;
|
||||||
sendMessage: (channel: string, message: Object) => void;
|
sendMessage: (channel: string, message: Object) => void;
|
||||||
addMessageListener(channel: string, callback: (message: any) => void): any;
|
addMessageListener(channel: string, callback: (message: any) => void): any;
|
||||||
removeMessageListener(channel: string, callback: (message: any) => void): any;
|
removeMessageListener(channel: string, callback: (message: any) => void): any;
|
||||||
on(event: RtmtEventTypes, callback: (...value: any[]) => void): this;
|
on(event: RtmtEventTypes, callback: (...value: any[]) => void): this;
|
||||||
off(event: RtmtEventTypes, callback: (...value: any[]) => void): this;
|
off(event: RtmtEventTypes, callback: (...value: any[]) => void): this;
|
||||||
dispose(): any;
|
dispose(): any;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { channel_ping, channel_pong } from "../channel_names";
|
import { channel_auth, channel_on_auth, channel_on_auth_error, channel_ping, channel_pong } from "../channel_names";
|
||||||
import { decode, encode } from "../encode_decode_message";
|
import { decode, encode } from "../encode_decode_message";
|
||||||
import { ConnectionState, RTMT, RTMT_WS_PING_INTERVAL, RTMT_WS_PING_INTERVAL_BUFFER_TIME, RtmtEventTypes } from "./rtmt";
|
import { ConnectionState, RTMT, RTMT_WS_PING_INTERVAL, RTMT_WS_PING_INTERVAL_BUFFER_TIME, RtmtEventTypes, TokenProvider } from "./rtmt";
|
||||||
import { TinyEmitter } from "tiny-emitter";
|
import { TinyEmitter } from "tiny-emitter";
|
||||||
|
import { AuthUser } from "../../models/AuthUser";
|
||||||
|
|
||||||
const MESSAGE_CHANNEL_PREFIX = "message_channel";
|
const MESSAGE_CHANNEL_PREFIX = "message_channel";
|
||||||
|
|
||||||
@ -9,13 +10,18 @@ export class RTMTWS extends TinyEmitter implements RTMT {
|
|||||||
private webSocket?: WebSocket;
|
private webSocket?: WebSocket;
|
||||||
private pingTimeout?: number = undefined;
|
private pingTimeout?: number = undefined;
|
||||||
private _connectionState: ConnectionState = "none";
|
private _connectionState: ConnectionState = "none";
|
||||||
private userKey: string = "";
|
|
||||||
private url: string = "";
|
private url: string = "";
|
||||||
|
private getToken?: TokenProvider;
|
||||||
|
private _user?: AuthUser;
|
||||||
|
|
||||||
get connectionState(): ConnectionState {
|
get connectionState(): ConnectionState {
|
||||||
return this._connectionState;
|
return this._connectionState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get user(): AuthUser | undefined {
|
||||||
|
return this._user;
|
||||||
|
}
|
||||||
|
|
||||||
protected setConnectionState(connectionState: ConnectionState) {
|
protected setConnectionState(connectionState: ConnectionState) {
|
||||||
this._connectionState = connectionState;
|
this._connectionState = connectionState;
|
||||||
this.emit("connectionchange", this._connectionState);
|
this.emit("connectionchange", this._connectionState);
|
||||||
@ -38,13 +44,20 @@ export class RTMTWS extends TinyEmitter implements RTMT {
|
|||||||
this.webSocket = undefined;
|
this.webSocket = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
public connectWebSocket(url: string, userKey: string) {
|
// With getToken the connection authenticates with a token and is "connected" once the server accepts it.
|
||||||
|
public connectWebSocket(url: string, getToken?: TokenProvider) {
|
||||||
this.setConnectionState("connecting");
|
this.setConnectionState("connecting");
|
||||||
this.userKey = userKey;
|
|
||||||
this.url = url;
|
this.url = url;
|
||||||
|
this.getToken = getToken;
|
||||||
this.createWebSocket(url);
|
this.createWebSocket(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sends a refreshed token for the current connection.
|
||||||
|
public reauthenticate(token: string) {
|
||||||
|
if (this._connectionState !== "connected" || !this.getToken) return;
|
||||||
|
this.sendMessage(channel_auth, { token });
|
||||||
|
}
|
||||||
|
|
||||||
private reconnectWebSocket() {
|
private reconnectWebSocket() {
|
||||||
this.setConnectionState("reconnecting");
|
this.setConnectionState("reconnecting");
|
||||||
this.disposeWebSocket();
|
this.disposeWebSocket();
|
||||||
@ -53,10 +66,30 @@ export class RTMTWS extends TinyEmitter implements RTMT {
|
|||||||
|
|
||||||
protected onWebSocketOpen(webSocket: WebSocket) {
|
protected onWebSocketOpen(webSocket: WebSocket) {
|
||||||
this.webSocket = webSocket;
|
this.webSocket = webSocket;
|
||||||
this.setConnectionState("connected");
|
|
||||||
console.info("(RTMT) WebSocket has opened");
|
console.info("(RTMT) WebSocket has opened");
|
||||||
this.heartbeat();
|
this.heartbeat();
|
||||||
this.emit("open");
|
if (!this.getToken) {
|
||||||
|
this.setConnectionState("connected");
|
||||||
|
this.emit("open");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.getToken().then((token) => {
|
||||||
|
if (this.webSocket === webSocket) this.sendMessage(channel_auth, { token });
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error("(RTMT) Could not get a token", error);
|
||||||
|
this.emit("error", error);
|
||||||
|
webSocket.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private onAuthenticated(user: AuthUser) {
|
||||||
|
const isNewConnection = this._connectionState !== "connected";
|
||||||
|
this._user = user;
|
||||||
|
this.emit("authenticated", user);
|
||||||
|
if (isNewConnection) {
|
||||||
|
this.setConnectionState("connected");
|
||||||
|
this.emit("open");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onWebSocketMessage(webSocket: WebSocket, event: any) {
|
protected onWebSocketMessage(webSocket: WebSocket, event: any) {
|
||||||
@ -105,6 +138,15 @@ export class RTMTWS extends TinyEmitter implements RTMT {
|
|||||||
this.sendMessage(channel_pong, {});
|
this.sendMessage(channel_pong, {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (channel === channel_on_auth) {
|
||||||
|
this.onAuthenticated((message as { user: AuthUser }).user);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (channel === channel_on_auth_error) {
|
||||||
|
// The server closes the socket next; reconnecting fetches a fresh token.
|
||||||
|
console.error("(RTMT) Authentication failed", message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// TODO: Maybe we should warn if there is not any listener for channel
|
// TODO: Maybe we should warn if there is not any listener for channel
|
||||||
this.emit(MESSAGE_CHANNEL_PREFIX + channel, message);
|
this.emit(MESSAGE_CHANNEL_PREFIX + channel, message);
|
||||||
}
|
}
|
||||||
|
|||||||
15
packages/core/src/util/PlayerUtil.ts
Normal file
15
packages/core/src/util/PlayerUtil.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
const GUEST_USERNAME_PREFIX = "guest-";
|
||||||
|
|
||||||
|
// Name to show for a player, or undefined if the server sent none (legacy userKey players).
|
||||||
|
// Guests show as e.g. "Guest 4F2A" instead of their generated username.
|
||||||
|
export function getPlayerDisplayName(
|
||||||
|
player: { name?: string, isGuest?: boolean } | undefined,
|
||||||
|
t: (key: string) => string
|
||||||
|
): string | undefined {
|
||||||
|
if (!player?.name) return undefined;
|
||||||
|
if (player.isGuest) {
|
||||||
|
const suffix = player.name.startsWith(GUEST_USERNAME_PREFIX) ? player.name.slice(GUEST_USERNAME_PREFIX.length) : player.name;
|
||||||
|
return `${t("Guest")} ${suffix.slice(0, 4).toUpperCase()}`;
|
||||||
|
}
|
||||||
|
return player.name;
|
||||||
|
}
|
||||||
@ -1 +1,2 @@
|
|||||||
export * from './ColorUtil'
|
export * from './ColorUtil'
|
||||||
|
export * from './PlayerUtil'
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user