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:
@@ -21,11 +21,7 @@ import {
|
||||
Match,
|
||||
} from "solid-js";
|
||||
import { sendMessage, useJournalStream } from "../stores/journalStream";
|
||||
import {
|
||||
useJournalCompletions,
|
||||
ensureCompletions,
|
||||
type CompletionsState,
|
||||
} from "./completions";
|
||||
import { useJournalCompletions, ensureCompletions } from "./completions";
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
@@ -78,6 +74,10 @@ export const JournalInput: Component = () => {
|
||||
const stream = useJournalStream();
|
||||
const comp = useJournalCompletions();
|
||||
|
||||
const isObserver = () => stream.myRole === "observer";
|
||||
const isPlayer = () => stream.myRole === "player";
|
||||
const isGm = () => stream.myRole === "gm";
|
||||
|
||||
const [text, setText] = createSignal("");
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [sending, setSending] = createSignal(false);
|
||||
@@ -98,6 +98,18 @@ export const JournalInput: Component = () => {
|
||||
const raw = text().trim();
|
||||
if (!raw) return;
|
||||
|
||||
// Players can't use commands — everything is narrative
|
||||
if (isPlayer() || isObserver()) {
|
||||
const result = sendMessage("narrative", { text: raw });
|
||||
if (!result.success) {
|
||||
setError(result.error);
|
||||
} else {
|
||||
setText("");
|
||||
}
|
||||
textareaRef?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseInput(raw);
|
||||
if (parsed.error) {
|
||||
setError(parsed.error);
|
||||
@@ -132,6 +144,8 @@ export const JournalInput: Component = () => {
|
||||
|
||||
function buildCompletions(): CompletionItem[] {
|
||||
const raw = text().trim();
|
||||
// Only GM gets completions
|
||||
if (!isGm()) return [];
|
||||
const data = comp.data;
|
||||
const commands = [
|
||||
{ label: "/roll", kind: "command" as const, insertText: "/roll " },
|
||||
@@ -235,9 +249,9 @@ export const JournalInput: Component = () => {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// If not open and starts with /, open completions
|
||||
// If not open and starts with /, open completions (GM only)
|
||||
const raw = text();
|
||||
if (raw.startsWith("/")) {
|
||||
if (isGm() && raw.startsWith("/")) {
|
||||
e.preventDefault();
|
||||
setShowCompletions(true);
|
||||
setSelectedIdx(0);
|
||||
@@ -280,8 +294,8 @@ export const JournalInput: Component = () => {
|
||||
const raw = input.value;
|
||||
setText(raw);
|
||||
|
||||
// Completions visibility — keep open while user types any /
|
||||
if (raw.startsWith("/")) {
|
||||
// Completions visibility — keep open while user types any / (GM only)
|
||||
if (isGm() && raw.startsWith("/")) {
|
||||
setShowCompletions(true);
|
||||
setSelectedIdx(0);
|
||||
} else {
|
||||
@@ -386,35 +400,49 @@ export const JournalInput: Component = () => {
|
||||
|
||||
{/* Textarea + actions */}
|
||||
<div class="flex flex-col">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text()}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a message, or /roll or /link..."
|
||||
rows={2}
|
||||
class="w-full resize-none border-0 px-3 pt-2.5 pb-1 text-sm
|
||||
text-gray-800 placeholder-gray-400 focus:outline-none
|
||||
bg-transparent min-h-[60px]"
|
||||
/>
|
||||
<Show
|
||||
when={!isObserver()}
|
||||
fallback={
|
||||
<div class="px-3 py-4 text-xs text-gray-400 italic text-center">
|
||||
You are observing. Open this panel on another device to join as GM
|
||||
or player.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text()}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
isGm()
|
||||
? "Type a message, or /roll or /link..."
|
||||
: "Type a message..."
|
||||
}
|
||||
rows={2}
|
||||
class="w-full resize-none border-0 px-3 pt-2.5 pb-1 text-sm
|
||||
text-gray-800 placeholder-gray-400 focus:outline-none
|
||||
bg-transparent min-h-[60px]"
|
||||
/>
|
||||
|
||||
{/* Bottom row: error + buttons */}
|
||||
<div class="flex items-center justify-between px-2 pb-2">
|
||||
<Show when={error()}>
|
||||
<p class="text-red-500 text-xs truncate max-w-[60%]">{error()}</p>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1 ml-auto">
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={sending() || !text().trim()}
|
||||
class="bg-blue-600 text-white rounded px-3 py-1 text-xs
|
||||
{/* Bottom row: error + buttons */}
|
||||
<div class="flex items-center justify-between px-2 pb-2">
|
||||
<Show when={error()}>
|
||||
<p class="text-red-500 text-xs truncate max-w-[60%]">{error()}</p>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1 ml-auto">
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={sending() || !text().trim()}
|
||||
class="bg-blue-600 text-white rounded px-3 py-1 text-xs
|
||||
hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user