feat: implement role-based access control
Introduce 'gm', 'player', and 'observer' roles to the journal system. This includes: - Restricting command usage and completions to the GM role. - Implementing role-based message emission permissions. - Adding a player list and role indicators in the UI. - Persisting the user's role in localStorage. - Updating the connection logic to include the role in the client ID.
This commit is contained in:
@@ -41,8 +41,12 @@ export interface JournalStreamState {
|
||||
connectionError: string | null;
|
||||
/** This client's identity */
|
||||
myName: string;
|
||||
/** Role: gm | player | observer. Immutable while connected. */
|
||||
myRole: "gm" | "player" | "observer";
|
||||
/** Broker URL, set after connect */
|
||||
brokerUrl: string | null;
|
||||
/** Active player list (keyed by player name) */
|
||||
players: Record<string, { role: string }>;
|
||||
}
|
||||
|
||||
export interface SessionMeta {
|
||||
@@ -62,19 +66,22 @@ export interface SessionManifest {
|
||||
const LS_PLAYER_NAME = "ttrpg.playerName";
|
||||
const LS_BROKER_URL = "ttrpg.brokerUrl";
|
||||
const LS_LAST_SESSION = "ttrpg.lastSessionId";
|
||||
const LS_PLAYER_ROLE = "ttrpg.playerRole";
|
||||
|
||||
function loadPersisted(): {
|
||||
myName: string;
|
||||
brokerUrl: string | null;
|
||||
lastSessionId: string | null;
|
||||
myRole: string;
|
||||
} {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return { myName: "gm", brokerUrl: null, lastSessionId: null };
|
||||
return { myName: "gm", brokerUrl: null, lastSessionId: null, myRole: "gm" };
|
||||
}
|
||||
return {
|
||||
myName: localStorage.getItem(LS_PLAYER_NAME) || "gm",
|
||||
brokerUrl: localStorage.getItem(LS_BROKER_URL),
|
||||
lastSessionId: localStorage.getItem(LS_LAST_SESSION),
|
||||
myRole: localStorage.getItem(LS_PLAYER_ROLE) || "gm",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -99,7 +106,9 @@ const [state, setState] = createStore<JournalStreamState>({
|
||||
connectionStatus: "disconnected",
|
||||
connectionError: null,
|
||||
myName: initialName,
|
||||
myRole: (persisted.myRole as "gm" | "player" | "observer") || "gm",
|
||||
brokerUrl: persisted.brokerUrl,
|
||||
players: {},
|
||||
});
|
||||
|
||||
// Sync initial URL params if they came from localStorage (not URL)
|
||||
@@ -127,6 +136,17 @@ export function setMyName(name: string): void {
|
||||
syncUrlParam("player", name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the current player's role. Persisted to localStorage.
|
||||
* Only callable when disconnected.
|
||||
*/
|
||||
export function setMyRole(role: "gm" | "player" | "observer"): void {
|
||||
setState("myRole", role);
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.setItem(LS_PLAYER_ROLE, role);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active session ID and sync to URL. Also resolves the human-readable
|
||||
* session name from the cached manifest.
|
||||
@@ -270,7 +290,7 @@ export async function connectStream(
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const client = mqtt.connect(brokerUrl, {
|
||||
clientId: `${state.myName}-${Date.now()}`,
|
||||
clientId: `${state.myName}-${state.myRole}-${Date.now()}`,
|
||||
protocol: brokerUrl.startsWith("wss") ? "wss" : "ws",
|
||||
reconnectPeriod: 2000,
|
||||
});
|
||||
@@ -297,6 +317,19 @@ export async function connectStream(
|
||||
if (err) console.error("[stream] sessions sub err:", err);
|
||||
});
|
||||
client.subscribe(`ttrpg/${sessionId}/meta`, { qos: 1 });
|
||||
// Presence tracking
|
||||
client.subscribe(`ttrpg/${sessionId}/presence/+`, { qos: 1 });
|
||||
|
||||
// Publish own presence (retained)
|
||||
const presenceData = JSON.stringify({
|
||||
name: state.myName,
|
||||
role: state.myRole,
|
||||
});
|
||||
client.publish(
|
||||
`ttrpg/${sessionId}/presence/${state.myName}`,
|
||||
presenceData,
|
||||
{ qos: 1, retain: true },
|
||||
);
|
||||
|
||||
resolve();
|
||||
});
|
||||
@@ -340,6 +373,33 @@ export async function connectStream(
|
||||
return;
|
||||
}
|
||||
|
||||
// Presence: ttrpg/{sessionId}/presence/{playerName}
|
||||
if (
|
||||
parts.length >= 4 &&
|
||||
parts[0] === "ttrpg" &&
|
||||
parts[2] === "presence"
|
||||
) {
|
||||
const playerName = parts[3];
|
||||
if (raw) {
|
||||
try {
|
||||
const presence = JSON.parse(raw);
|
||||
setState("players", playerName, {
|
||||
role: presence.role || "player",
|
||||
});
|
||||
} catch {
|
||||
setState("players", playerName, { role: "player" });
|
||||
}
|
||||
} else {
|
||||
// Tombstone — player disconnected
|
||||
setState(
|
||||
produce((s) => {
|
||||
delete s.players[playerName];
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parts.length >= 3 && parts[0] === "ttrpg" && parts[2] === "stream") {
|
||||
try {
|
||||
const msg: StreamMessage = JSON.parse(raw);
|
||||
@@ -505,12 +565,24 @@ export function revertLatest():
|
||||
|
||||
export function disconnectStream(): void {
|
||||
if (_mqttClient) {
|
||||
_mqttClient.end(true);
|
||||
// Clear our presence before disconnecting (tombstone)
|
||||
const sessionId = state.sessionId;
|
||||
if (sessionId) {
|
||||
_mqttClient.publish(`ttrpg/${sessionId}/presence/${state.myName}`, "", {
|
||||
qos: 1,
|
||||
retain: true,
|
||||
});
|
||||
}
|
||||
// Force disconnect without reconnect
|
||||
_mqttClient.end(true, void 0, () => {
|
||||
// noop
|
||||
});
|
||||
_mqttClient = null;
|
||||
_mqttConnected = false;
|
||||
}
|
||||
setState("connected", false);
|
||||
setState("connectionStatus", "disconnected");
|
||||
setState("players", {});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user