feat(frontend): log in with Keycloak, as a guest or with an account
- First visit logs in as a new guest through Keycloak without a form; the session, including an offline refresh token, is kept in local storage - Account menu on the home page: guests can log in or sign up, users can log out (which starts a new guest session) - The WebSocket client in core authenticates with the access token after connecting, reports "connected" once the server accepts it, and re-sends renewed tokens on the open connection - Game page shows player names; guests appear as "Guest 4F2A" - Auth channels, AuthUser and player names in UserConnectionInfo move to core and the backend uses them from there - Mobile keeps using userKey login for now
This commit is contained in:
parent
e165b0a0eb
commit
f5dcf930fa
@ -1,7 +1 @@
|
||||
export interface AuthUser {
|
||||
// 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;
|
||||
}
|
||||
export type { AuthUser } from "@mancala/core";
|
||||
|
||||
@ -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 { GameCrashManager } from "./GameCrashManager";
|
||||
import { MatchMaker } from "../matchmaker/MatchMaker";
|
||||
import { GameUsersConnectionInfo } from "@mancala/core";
|
||||
import { BackendGame, PlayerConnectionInfo, PlayerInfo } from "../models/Game";
|
||||
import { GameUsersConnectionInfo, UserConnectionInfo } from "@mancala/core";
|
||||
import { BackendGame, PlayerInfo } from "../models/Game";
|
||||
|
||||
export class GameManager {
|
||||
gameStore: GameStore;
|
||||
@ -176,7 +176,7 @@ export class GameManager {
|
||||
}
|
||||
|
||||
private getGameUsersConnectionInfoFromPlayers(player1: PlayerInfo, player2: PlayerInfo): GameUsersConnectionInfo {
|
||||
const toConnectionInfo = (player: PlayerInfo): PlayerConnectionInfo =>
|
||||
const toConnectionInfo = (player: PlayerInfo): UserConnectionInfo =>
|
||||
({ ...player, isOnline: this.rtmt.isClientOnline(player.userId) });
|
||||
return {
|
||||
user1ConnectionInfo: toConnectionInfo(player1),
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Game, UserConnectionInfo } from "@mancala/core";
|
||||
import { Game } from "@mancala/core";
|
||||
|
||||
export interface PlayerInfo {
|
||||
userId: string;
|
||||
@ -7,9 +7,6 @@ export interface PlayerInfo {
|
||||
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 {
|
||||
spectatorIds: string[];
|
||||
// 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 * 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 { decode, encode, Message } from "./encode_decode_message";
|
||||
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 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 WebSocket from "ws";
|
||||
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";
|
||||
|
||||
const LEGACY_KEY = "0b9f6d1e-7c1a-4f7e-9a51-3f0c2b6d8e11";
|
||||
|
||||
@ -18,6 +18,8 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/frontend/Dockerfile
|
||||
args:
|
||||
KEYCLOAK_URL: ${KEYCLOAK_HOSTNAME:-http://localhost:8080}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
keycloak-db:
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# Build from the repo root: docker build -f apps/frontend/Dockerfile .
|
||||
FROM node:20-alpine3.19 AS build
|
||||
|
||||
ARG SERVER_ADDRESS WS_SERVER_ADDRESS
|
||||
ARG SERVER_ADDRESS WS_SERVER_ADDRESS KEYCLOAK_URL
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@ -18,10 +18,24 @@ yarn dev
|
||||
|
||||
### 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
|
||||
|
||||
From the repo root:
|
||||
|
||||
```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",
|
||||
"mancala.js": "link:../../packages/mancala.js",
|
||||
"notyf": "^3.10.0",
|
||||
"oidc-client-ts": "3.5.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-i18next": "^14.1.2",
|
||||
|
||||
@ -10,7 +10,7 @@ import GamePage from './routes/GamePage';
|
||||
import Home from './routes/Home';
|
||||
|
||||
import { initContext } from './context/context';
|
||||
import { RTMTWS, ConnectionState } from '@mancala/core';
|
||||
import { RTMTWS, ConnectionState, AuthUser } from '@mancala/core';
|
||||
import { getColorByBrightness } from "@mancala/core";
|
||||
import { Theme } from '@mancala/core';
|
||||
import LobyPage from './routes/LobyPage';
|
||||
@ -26,7 +26,13 @@ const MancalaApp: FunctionComponent = () => {
|
||||
|
||||
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");
|
||||
|
||||
@ -44,35 +50,66 @@ const MancalaApp: FunctionComponent = () => {
|
||||
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;
|
||||
rtmt.on("error", onConnectionError);
|
||||
rtmt.on("connectionchange", onConnectionChange)
|
||||
const url = server.wsServerAdress + "?userKey=" + userKey;
|
||||
rtmt.connectWebSocket(url, userKey)
|
||||
rtmt.on("connectionchange", onConnectionChange);
|
||||
rtmt.on("authenticated", onAuthenticated);
|
||||
rtmt.connectWebSocket(server.wsServerAdress, getAccessTokenOrLoginAgain);
|
||||
return rtmt;
|
||||
}
|
||||
|
||||
const loadUserKeyAndConnectServer = () => {
|
||||
context.userKeyStore.getUserKey().then((userKey: string) => {
|
||||
setUserKey(userKey);
|
||||
connectRTMT(userKey);
|
||||
}).catch((error) => {
|
||||
//TODO: check if it is network error!
|
||||
swal(t("Error") + "!", t("ErrorWhenRetrievingInformation"), "error");
|
||||
console.error(error);
|
||||
});
|
||||
const startSession = async () => {
|
||||
const authService = context.authService;
|
||||
if (authService.isCallback()) {
|
||||
let returnTo = "/";
|
||||
try {
|
||||
returnTo = await authService.handleCallback();
|
||||
} catch (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 = () => {
|
||||
context.rtmt?.dispose();
|
||||
context.rtmt.off("authenticated", onAuthenticated);
|
||||
context.themeManager.off("themechange", onThemeChange);
|
||||
}
|
||||
|
||||
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);
|
||||
return () => disposeApp();
|
||||
return () => {
|
||||
removeUserLoadedListener();
|
||||
disposeApp();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const textColorOnBoard = getColorByBrightness(
|
||||
@ -82,9 +119,9 @@ const MancalaApp: FunctionComponent = () => {
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<BrowserRouter>
|
||||
{hasSession && <BrowserRouter>
|
||||
<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="game" >
|
||||
<Route path=":gameId" element={<GamePage context={context} theme={theme} userKey={userKey} />} ></Route>
|
||||
@ -95,7 +132,7 @@ const MancalaApp: FunctionComponent = () => {
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</BrowserRouter>}
|
||||
<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>
|
||||
</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,
|
||||
}
|
||||
|
||||
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;
|
||||
@ -1,14 +1,14 @@
|
||||
import { server } from "../const/config";
|
||||
import { keycloak, server } from "../const/config";
|
||||
import { RTMT, RTMTWS, ThemeManager } from "@mancala/core";
|
||||
import { HttpServiceImpl } from "../service/HttpService";
|
||||
import { GameStore, GameStoreImpl } from "../store/GameStore";
|
||||
import { UserKeyStore, UserKeyStoreImpl } from "../store/KeyStore";
|
||||
import { AuthService } from "../auth/AuthService";
|
||||
import { LocalStorage } from "../storage";
|
||||
import { Storage } from '@mancala/core'
|
||||
|
||||
export type Context = {
|
||||
rtmt: RTMT;
|
||||
userKeyStore: UserKeyStore;
|
||||
authService: AuthService;
|
||||
themeManager: ThemeManager;
|
||||
gameStore: GameStore;
|
||||
storage: Storage;
|
||||
@ -17,13 +17,13 @@ export type Context = {
|
||||
export const initContext = (): Context => {
|
||||
const rtmt = new RTMTWS();
|
||||
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 storage = new LocalStorage();
|
||||
const themeManager = new ThemeManager(storage);
|
||||
return {
|
||||
rtmt: rtmt,
|
||||
userKeyStore: userKeyStore,
|
||||
authService,
|
||||
themeManager,
|
||||
gameStore,
|
||||
storage
|
||||
|
||||
@ -22,7 +22,7 @@ import { Context } from '../context/context';
|
||||
import useWindowDimensions from '../hooks/useWindowDimensions';
|
||||
import { GameMove, LoadingState, Game, GameUsersConnectionInfo } 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 Center from '../components/Center';
|
||||
import notyf from '../util/Notyf';
|
||||
@ -84,6 +84,11 @@ const GamePage: FunctionComponent<{
|
||||
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 newGame: Game = message as Game;
|
||||
newGame.mancalaGame = MancalaGame.createFromMancalaGame(newGame.mancalaGame);
|
||||
@ -230,17 +235,19 @@ const GamePage: FunctionComponent<{
|
||||
const showBoardView = game && boardViewModel && userKey && true;
|
||||
const topLocatedUserId = (isSpectator ? mancalaGame?.player2Id : getOpponentId()) || "0";
|
||||
const bottomLocatedUserId = (isSpectator ? mancalaGame?.player1Id : userKey) || "1";
|
||||
const topLocatedUserName = getPlayerName(topLocatedUserId);
|
||||
const bottomLocatedUserName = getPlayerName(bottomLocatedUserId);
|
||||
const topLocatedUser = {
|
||||
id: topLocatedUserId,
|
||||
name: "Anonymous",
|
||||
name: topLocatedUserName ?? "Anonymous",
|
||||
isOnline: isUserOnline(topLocatedUserId),
|
||||
isAnonymous: true
|
||||
isAnonymous: !topLocatedUserName
|
||||
};
|
||||
const bottomLocatedUser = {
|
||||
id: bottomLocatedUserId,
|
||||
name: "Anonymous",
|
||||
name: bottomLocatedUserName ?? "Anonymous",
|
||||
isOnline: isSpectator ? isUserOnline(bottomLocatedUserId) : context.rtmt.connectionState === "connected",
|
||||
isAnonymous: true
|
||||
isAnonymous: !bottomLocatedUserName
|
||||
};
|
||||
const currentUser = isSpectator ? {
|
||||
id: "2",
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import * as React from "react";
|
||||
import { FunctionComponent, useEffect, useState } from "react";
|
||||
import { getColorByBrightness } from "@mancala/core";
|
||||
import { Theme } from "@mancala/core";
|
||||
import { AuthUser, Theme } from "@mancala/core";
|
||||
import HeaderBar from "../components/headerbar/HeaderBar";
|
||||
import PageContainer from "../components/PageContainer";
|
||||
import Row from "../components/Row";
|
||||
import HeaderbarIcon from "../components/headerbar/HeaderbarIcon";
|
||||
import HeaderbarTitle from "../components/headerbar/HeaderbarTitle";
|
||||
import ThemeSwitchMenu from "../components/headerbar/ThemeSwitchMenu";
|
||||
import AccountMenu from "../components/headerbar/AccountMenu";
|
||||
import Button from "../components/Button";
|
||||
import { Context } from "../context/context";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
@ -17,8 +18,9 @@ import { useTranslation } from "react-i18next";
|
||||
const Home: FunctionComponent<{
|
||||
context: Context,
|
||||
userKey?: string,
|
||||
authUser?: AuthUser,
|
||||
theme: Theme,
|
||||
}> = ({ context, userKey, theme }) => {
|
||||
}> = ({ context, userKey, authUser, theme }) => {
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
@ -47,6 +49,7 @@ const Home: FunctionComponent<{
|
||||
</Row>
|
||||
<Row>
|
||||
<ThemeSwitchMenu context={context} textColor={textColorOnAppBar} />
|
||||
<AccountMenu context={context} textColor={textColorOnAppBar} authUser={authUser} />
|
||||
<Button
|
||||
context={context}
|
||||
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"
|
||||
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:
|
||||
version "1.2.4"
|
||||
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"
|
||||
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:
|
||||
version "1.3.0"
|
||||
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("connectionchange", onConnectionChange)
|
||||
const url = server.wsServerAdress + "?userKey=" + userKey;
|
||||
rtmt.connectWebSocket(url, userKey)
|
||||
// Legacy userKey login; mobile moves to Keycloak tokens later.
|
||||
rtmt.connectWebSocket(url)
|
||||
return rtmt;
|
||||
}
|
||||
const getTextByConnectionState = (context: Context, connectionState: ConnectionState): string => {
|
||||
|
||||
@ -38,5 +38,9 @@ export const en = {
|
||||
WebApp: "Goto WebApp",
|
||||
Privacy: "Open Privacy Policy",
|
||||
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",
|
||||
Privacy: "Gizlilik Politikasını Göster",
|
||||
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 {
|
||||
userId: string;
|
||||
isOnline: boolean;
|
||||
// Sent by servers with Keycloak auth; undefined for legacy userKey players.
|
||||
name?: string;
|
||||
isGuest?: boolean;
|
||||
}
|
||||
@ -3,3 +3,4 @@ export * from './GameMove'
|
||||
export * from './LoadingState'
|
||||
export * from './User'
|
||||
export * from './UserConnectionInfo'
|
||||
export * from './AuthUser'
|
||||
@ -12,3 +12,10 @@ export const channel_pong = "pong"
|
||||
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_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,15 +1,21 @@
|
||||
import { TinyEmitter } from "tiny-emitter"
|
||||
import { AuthUser } from "../../models/AuthUser";
|
||||
|
||||
export type OnMessage = (message: Object) => any
|
||||
|
||||
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;
|
||||
|
||||
// Supplies a current access token; called on every (re)connect.
|
||||
export type TokenProvider = () => Promise<string>;
|
||||
|
||||
export interface RTMT extends TinyEmitter {
|
||||
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;
|
||||
addMessageListener(channel: string, callback: (message: any) => void): any;
|
||||
removeMessageListener(channel: string, callback: (message: any) => void): 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 { 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 { AuthUser } from "../../models/AuthUser";
|
||||
|
||||
const MESSAGE_CHANNEL_PREFIX = "message_channel";
|
||||
|
||||
@ -9,13 +10,18 @@ export class RTMTWS extends TinyEmitter implements RTMT {
|
||||
private webSocket?: WebSocket;
|
||||
private pingTimeout?: number = undefined;
|
||||
private _connectionState: ConnectionState = "none";
|
||||
private userKey: string = "";
|
||||
private url: string = "";
|
||||
private getToken?: TokenProvider;
|
||||
private _user?: AuthUser;
|
||||
|
||||
get connectionState(): ConnectionState {
|
||||
return this._connectionState;
|
||||
}
|
||||
|
||||
get user(): AuthUser | undefined {
|
||||
return this._user;
|
||||
}
|
||||
|
||||
protected setConnectionState(connectionState: ConnectionState) {
|
||||
this._connectionState = connectionState;
|
||||
this.emit("connectionchange", this._connectionState);
|
||||
@ -38,13 +44,20 @@ export class RTMTWS extends TinyEmitter implements RTMT {
|
||||
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.userKey = userKey;
|
||||
this.url = url;
|
||||
this.getToken = getToken;
|
||||
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() {
|
||||
this.setConnectionState("reconnecting");
|
||||
this.disposeWebSocket();
|
||||
@ -53,10 +66,30 @@ export class RTMTWS extends TinyEmitter implements RTMT {
|
||||
|
||||
protected onWebSocketOpen(webSocket: WebSocket) {
|
||||
this.webSocket = webSocket;
|
||||
this.setConnectionState("connected");
|
||||
console.info("(RTMT) WebSocket has opened");
|
||||
this.heartbeat();
|
||||
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) {
|
||||
@ -105,6 +138,15 @@ export class RTMTWS extends TinyEmitter implements RTMT {
|
||||
this.sendMessage(channel_pong, {});
|
||||
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
|
||||
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 './PlayerUtil'
|
||||
|
||||
Loading…
Reference in New Issue
Block a user