Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3

Merged
jhalitaksoy merged 88 commits from develop into main 2026-10-17 03:23:38 +03:00
30 changed files with 369 additions and 132 deletions
Showing only changes of commit f5dcf930fa - Show all commits

View File

@ -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;
}

View File

@ -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;

View File

@ -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),

View File

@ -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.

View File

@ -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;

View File

@ -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";

View File

@ -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:

View File

@ -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

View File

@ -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 .
``` ```

View File

@ -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",

View File

@ -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>

View 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;
}

View 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;

View File

@ -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;

View File

@ -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

View File

@ -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",

View File

@ -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}

View File

@ -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)
}
}

View File

@ -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"

View File

@ -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 => {

View File

@ -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"
} }

View File

@ -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"
}; };

View 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;
}

View File

@ -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;
} }

View File

@ -3,3 +3,4 @@ export * from './GameMove'
export * from './LoadingState' export * from './LoadingState'
export * from './User' export * from './User'
export * from './UserConnectionInfo' export * from './UserConnectionInfo'
export * from './AuthUser'

View File

@ -12,3 +12,10 @@ 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

View File

@ -1,15 +1,21 @@
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;

View File

@ -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();
if (!this.getToken) {
this.setConnectionState("connected");
this.emit("open"); 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);
} }

View 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;
}

View File

@ -1 +1,2 @@
export * from './ColorUtil' export * from './ColorUtil'
export * from './PlayerUtil'