From a35f07b036ceef4662bac56be57c60d31d64d314 Mon Sep 17 00:00:00 2001 From: pinks Date: Mon, 23 Oct 2023 02:39:01 +0200 Subject: [PATCH] feat: managing admins in webui --- README.md | 10 +- api/adminsRoute.ts | 129 +++++++ api/paramsRoute.ts | 8 +- api/serveApi.ts | 6 +- api/usersRoute.ts | 23 +- api/withUser.ts | 40 ++- api/workersRoute.ts | 20 +- app/adminStore.ts | 24 ++ app/config.ts | 3 - bot/broadcastCommand.ts | 6 +- bot/mod.ts | 25 -- deno.json | 3 +- ui/AdminsPage.tsx | 250 ++++++++++++++ ui/App.tsx | 10 +- ui/AppHeader.tsx | 45 +-- ui/QueuePage.tsx | 6 +- ui/SettingsPage.tsx | 187 +++++----- ui/StatsPage.tsx | 22 +- ui/WorkersPage.tsx | 536 ++++++++++++++++------------- ui/{apiClient.tsx => apiClient.ts} | 0 ui/twind.ts | 13 +- ui/useLocalStorage.ts | 31 ++ 22 files changed, 946 insertions(+), 451 deletions(-) create mode 100644 api/adminsRoute.ts create mode 100644 app/adminStore.ts create mode 100644 ui/AdminsPage.tsx rename ui/{apiClient.tsx => apiClient.ts} (100%) create mode 100644 ui/useLocalStorage.ts diff --git a/README.md b/README.md index 3195041..ba3c1ae 100644 --- a/README.md +++ b/README.md @@ -13,17 +13,17 @@ You can put these in `.env` file or pass them as environment variables. - `TG_BOT_TOKEN` - Telegram bot token. Get yours from [@BotFather](https://t.me/BotFather). Required. -- `TG_ADMIN_USERNAMES` - Comma separated list of usernames of users that can use admin commands. - `DENO_KV_PATH` - [Deno KV](https://deno.land/api?s=Deno.openKv&unstable) database file path. A temporary file is used by default. - `LOG_LEVEL` - [Log level](https://deno.land/std@0.201.0/log/mod.ts?s=LogLevels). Default: `INFO`. -You can configure more stuff in [Eris WebUI](http://localhost:5999/) when running. - ## Running -- Start Stable Diffusion WebUI: `./webui.sh --api` (in SD WebUI directory) -- Start Eris: `deno task start` +1. Start Eris: `deno task start` +2. Visit [Eris WebUI](http://localhost:5999/) and login via Telegram. +3. Promote yourself to admin in the Eris WebUI. +4. Start Stable Diffusion WebUI: `./webui.sh --api` (in SD WebUI directory) +5. Add a new worker in the Eris WebUI. ## Codegen diff --git a/api/adminsRoute.ts b/api/adminsRoute.ts new file mode 100644 index 0000000..cb7fa3a --- /dev/null +++ b/api/adminsRoute.ts @@ -0,0 +1,129 @@ +import { Model } from "indexed_kv"; +import { info } from "std/log/mod.ts"; +import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server"; +import { Admin, adminSchema, adminStore } from "../app/adminStore.ts"; +import { getUser, withAdmin, withUser } from "./withUser.ts"; + +export type AdminData = Admin & { id: string }; + +function getAdminData(adminEntry: Model): AdminData { + return { id: adminEntry.id, ...adminEntry.value }; +} + +export const adminsRoute = createPathFilter({ + "": createMethodFilter({ + GET: createEndpoint( + {}, + async () => { + const adminEntries = await adminStore.getAll(); + const admins = adminEntries.map(getAdminData); + return { + status: 200, + body: { type: "application/json", data: admins satisfies AdminData[] }, + }; + }, + ), + POST: createEndpoint( + { + query: { + sessionId: { type: "string" }, + }, + body: { + type: "application/json", + schema: { + type: "object", + properties: { + tgUserId: adminSchema.properties.tgUserId, + }, + required: ["tgUserId"], + }, + }, + }, + async ({ query, body }) => { + return withAdmin(query, async (user, adminEntry) => { + const newAdminUser = await getUser(body.data.tgUserId); + const newAdminEntry = await adminStore.create({ + tgUserId: body.data.tgUserId, + promotedBy: adminEntry.id, + }); + info(`User ${user.first_name} promoted user ${newAdminUser.first_name} to admin`); + return { + status: 200, + body: { type: "application/json", data: getAdminData(newAdminEntry) }, + }; + }); + }, + ), + }), + + "promote_self": createMethodFilter({ + POST: createEndpoint( + { + query: { + sessionId: { type: "string" }, + }, + }, + // if there are no admins, allow any user to promote themselves + async ({ query }) => { + return withUser(query, async (user) => { + const adminEntries = await adminStore.getAll(); + if (adminEntries.length === 0) { + const newAdminEntry = await adminStore.create({ + tgUserId: user.id, + promotedBy: null, + }); + info(`User ${user.first_name} promoted themselves to admin`); + return { + status: 200, + body: { type: "application/json", data: getAdminData(newAdminEntry) }, + }; + } + return { + status: 403, + body: { type: "text/plain", data: `You are not allowed to promote yourself` }, + }; + }); + }, + ), + }), + + "{adminId}": createPathFilter({ + "": createMethodFilter({ + GET: createEndpoint( + {}, + async ({ params }) => { + const adminEntry = await adminStore.getById(params.adminId!); + if (!adminEntry) { + return { status: 404, body: { type: "text/plain", data: `Admin not found` } }; + } + return { + status: 200, + body: { type: "application/json", data: getAdminData(adminEntry) }, + }; + }, + ), + DELETE: createEndpoint( + { + query: { + sessionId: { type: "string" }, + }, + }, + async ({ params, query }) => { + const deletedAdminEntry = await adminStore.getById(params.adminId!); + if (!deletedAdminEntry) { + return { status: 404, body: { type: "text/plain", data: `Admin not found` } }; + } + const deletedAdminUser = await getUser(deletedAdminEntry.value.tgUserId); + return withUser(query, async (chat) => { + await deletedAdminEntry.delete(); + info(`User ${chat.first_name} demoted user ${deletedAdminUser.first_name} from admin`); + return { + status: 200, + body: { type: "application/json", data: null }, + }; + }); + }, + ), + }), + }), +}); diff --git a/api/paramsRoute.ts b/api/paramsRoute.ts index f0c4bad..23068d1 100644 --- a/api/paramsRoute.ts +++ b/api/paramsRoute.ts @@ -2,7 +2,7 @@ import { deepMerge } from "std/collections/deep_merge.ts"; import { info } from "std/log/mod.ts"; import { createEndpoint, createMethodFilter } from "t_rest/server"; import { configSchema, getConfig, setConfig } from "../app/config.ts"; -import { withUser } from "./withUser.ts"; +import { withAdmin } from "./withUser.ts"; export const paramsRoute = createMethodFilter({ GET: createEndpoint( @@ -22,13 +22,13 @@ export const paramsRoute = createMethodFilter({ }, }, async ({ query, body }) => { - return withUser(query, async (chat) => { + return withAdmin(query, async (user) => { const config = await getConfig(); - info(`User ${chat.username} updated default params: ${JSON.stringify(body.data)}`); + info(`User ${user.first_name} updated default params: ${JSON.stringify(body.data)}`); const defaultParams = deepMerge(config.defaultParams ?? {}, body.data); await setConfig({ defaultParams }); return { status: 200, body: { type: "application/json", data: config.defaultParams } }; - }, { admin: true }); + }); }, ), }); diff --git a/api/serveApi.ts b/api/serveApi.ts index c907be6..8559f46 100644 --- a/api/serveApi.ts +++ b/api/serveApi.ts @@ -1,4 +1,5 @@ import { createLoggerMiddleware, createPathFilter } from "t_rest/server"; +import { adminsRoute } from "./adminsRoute.ts"; import { botRoute } from "./botRoute.ts"; import { jobsRoute } from "./jobsRoute.ts"; import { paramsRoute } from "./paramsRoute.ts"; @@ -9,13 +10,14 @@ import { workersRoute } from "./workersRoute.ts"; export const serveApi = createLoggerMiddleware( createPathFilter({ + "admins": adminsRoute, + "bot": botRoute, "jobs": jobsRoute, "sessions": sessionsRoute, - "users": usersRoute, "settings/params": paramsRoute, "stats": statsRoute, + "users": usersRoute, "workers": workersRoute, - "bot": botRoute, }), { filterStatus: (status) => status >= 400 }, ); diff --git a/api/usersRoute.ts b/api/usersRoute.ts index e1a6694..0f4cc69 100644 --- a/api/usersRoute.ts +++ b/api/usersRoute.ts @@ -1,20 +1,18 @@ import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server"; -import { getConfig } from "../app/config.ts"; import { bot } from "../bot/mod.ts"; +import { getUser } from "./withUser.ts"; +import { adminStore } from "../app/adminStore.ts"; export const usersRoute = createPathFilter({ "{userId}/photo": createMethodFilter({ GET: createEndpoint( { query: null, body: null }, async ({ params }) => { - const chat = await bot.api.getChat(params.userId!); - if (chat.type !== "private") { - throw new Error("Chat is not private"); - } - const photoData = chat.photo?.small_file_id + const user = await getUser(Number(params.userId!)); + const photoData = user.photo?.small_file_id ? await fetch( `https://api.telegram.org/file/bot${bot.token}/${await bot.api.getFile( - chat.photo.small_file_id, + user.photo.small_file_id, ).then((file) => file.file_path)}`, ).then((resp) => resp.arrayBuffer()) : undefined; @@ -36,17 +34,14 @@ export const usersRoute = createPathFilter({ GET: createEndpoint( { query: null, body: null }, async ({ params }) => { - const chat = await bot.api.getChat(params.userId!); - if (chat.type !== "private") { - throw new Error("Chat is not private"); - } - const config = await getConfig(); - const isAdmin = chat.username && config?.adminUsernames?.includes(chat.username); + const user = await getUser(Number(params.userId!)); + const [adminEntry] = await adminStore.getBy("tgUserId", { value: user.id }); + const admin = adminEntry?.value; return { status: 200, body: { type: "application/json", - data: { ...chat, isAdmin }, + data: { ...user, admin }, }, }; }, diff --git a/api/withUser.ts b/api/withUser.ts index 4a2d19e..ab637ac 100644 --- a/api/withUser.ts +++ b/api/withUser.ts @@ -1,28 +1,40 @@ import { Chat } from "grammy_types"; +import { Model } from "indexed_kv"; import { Output } from "t_rest/client"; -import { getConfig } from "../app/config.ts"; +import { Admin, adminStore } from "../app/adminStore.ts"; import { bot } from "../bot/mod.ts"; import { sessions } from "./sessionsRoute.ts"; export async function withUser( query: { sessionId: string }, cb: (user: Chat.PrivateGetChat) => Promise, - options?: { admin?: boolean }, ) { const session = sessions.get(query.sessionId); if (!session?.userId) { return { status: 401, body: { type: "text/plain", data: "Must be logged in" } } as const; } - const chat = await bot.api.getChat(session.userId); - if (chat.type !== "private") throw new Error("Chat is not private"); - if (options?.admin) { - if (!chat.username) { - return { status: 403, body: { type: "text/plain", data: "Must have a username" } } as const; - } - const config = await getConfig(); - if (!config?.adminUsernames?.includes(chat.username)) { - return { status: 403, body: { type: "text/plain", data: "Must be an admin" } } as const; - } - } - return cb(chat); + const user = await getUser(session.userId); + return cb(user); +} + +export async function withAdmin( + query: { sessionId: string }, + cb: (user: Chat.PrivateGetChat, admin: Model) => Promise, +) { + const session = sessions.get(query.sessionId); + if (!session?.userId) { + return { status: 401, body: { type: "text/plain", data: "Must be logged in" } } as const; + } + const user = await getUser(session.userId); + const [admin] = await adminStore.getBy("tgUserId", { value: session.userId }); + if (!admin) { + return { status: 403, body: { type: "text/plain", data: "Must be an admin" } } as const; + } + return cb(user, admin); +} + +export async function getUser(userId: number): Promise { + const chat = await bot.api.getChat(userId); + if (chat.type !== "private") throw new Error("Chat is not private"); + return chat; } diff --git a/api/workersRoute.ts b/api/workersRoute.ts index df65dbe..bf31c5a 100644 --- a/api/workersRoute.ts +++ b/api/workersRoute.ts @@ -13,7 +13,7 @@ import { } from "../app/workerInstanceStore.ts"; import { getAuthHeader } from "../utils/getAuthHeader.ts"; import { omitUndef } from "../utils/omitUndef.ts"; -import { withUser } from "./withUser.ts"; +import { withAdmin } from "./withUser.ts"; export type WorkerData = Omit & { id: string; @@ -89,14 +89,14 @@ export const workersRoute = createPathFilter({ }, }, async ({ query, body }) => { - return withUser(query, async (chat) => { + return withAdmin(query, async (user) => { const workerInstance = await workerInstanceStore.create(body.data); - info(`User ${chat.username} created worker ${workerInstance.id}`); + info(`User ${user.first_name} created worker ${workerInstance.value.name}`); return { status: 200, body: { type: "application/json", data: await getWorkerData(workerInstance) }, }; - }, { admin: true }); + }); }, ), }), @@ -131,9 +131,9 @@ export const workersRoute = createPathFilter({ if (!workerInstance) { return { status: 404, body: { type: "text/plain", data: `Worker not found` } }; } - return withUser(query, async (chat) => { + return withAdmin(query, async (user) => { info( - `User ${chat.username} updated worker ${params.workerId}: ${ + `User ${user.first_name} updated worker ${workerInstance.value.name}: ${ JSON.stringify(body.data) }`, ); @@ -142,7 +142,7 @@ export const workersRoute = createPathFilter({ status: 200, body: { type: "application/json", data: await getWorkerData(workerInstance) }, }; - }, { admin: true }); + }); }, ), DELETE: createEndpoint( @@ -157,11 +157,11 @@ export const workersRoute = createPathFilter({ if (!workerInstance) { return { status: 404, body: { type: "text/plain", data: `Worker not found` } }; } - return withUser(query, async (chat) => { - info(`User ${chat.username} deleted worker ${params.workerId}`); + return withAdmin(query, async (user) => { + info(`User ${user.first_name} deleted worker ${workerInstance.value.name}`); await workerInstance.delete(); return { status: 200, body: { type: "application/json", data: null } }; - }, { admin: true }); + }); }, ), }), diff --git a/app/adminStore.ts b/app/adminStore.ts new file mode 100644 index 0000000..4d85c70 --- /dev/null +++ b/app/adminStore.ts @@ -0,0 +1,24 @@ +import { Store } from "indexed_kv"; +import { JsonSchema, jsonType } from "t_rest/server"; +import { db } from "./db.ts"; + +export const adminSchema = { + type: "object", + properties: { + tgUserId: { type: "number" }, + promotedBy: { type: ["string", "null"] }, + }, + required: ["tgUserId", "promotedBy"], +} as const satisfies JsonSchema; + +export type Admin = jsonType; + +type AdminIndices = { + tgUserId: number; +}; + +export const adminStore = new Store(db, "adminUsers", { + indices: { + tgUserId: { getValue: (adminUser) => adminUser.tgUserId }, + }, +}); diff --git a/app/config.ts b/app/config.ts index 0b67c7d..d5a9669 100644 --- a/app/config.ts +++ b/app/config.ts @@ -4,7 +4,6 @@ import { JsonSchema, jsonType } from "t_rest/server"; export const configSchema = { type: "object", properties: { - adminUsernames: { type: "array", items: { type: "string" } }, pausedReason: { type: ["string", "null"] }, maxUserJobs: { type: "number" }, maxJobs: { type: "number" }, @@ -31,7 +30,6 @@ export async function getConfig(): Promise { const configEntry = await db.get(["config"]); const config = configEntry?.value; return { - adminUsernames: config?.adminUsernames ?? Deno.env.get("TG_ADMIN_USERNAMES")?.split(",") ?? [], pausedReason: config?.pausedReason ?? null, maxUserJobs: config?.maxUserJobs ?? Infinity, maxJobs: config?.maxJobs ?? Infinity, @@ -42,7 +40,6 @@ export async function getConfig(): Promise { export async function setConfig(newConfig: Partial): Promise { const oldConfig = await getConfig(); const config: Config = { - adminUsernames: newConfig.adminUsernames ?? oldConfig.adminUsernames, pausedReason: newConfig.pausedReason ?? oldConfig.pausedReason, maxUserJobs: newConfig.maxUserJobs ?? oldConfig.maxUserJobs, maxJobs: newConfig.maxJobs ?? oldConfig.maxJobs, diff --git a/bot/broadcastCommand.ts b/bot/broadcastCommand.ts index 5a72910..d1d23cf 100644 --- a/bot/broadcastCommand.ts +++ b/bot/broadcastCommand.ts @@ -2,7 +2,7 @@ import { CommandContext } from "grammy"; import { bold, fmt, FormattedString } from "grammy_parse_mode"; import { distinctBy } from "std/collections/distinct_by.ts"; import { error, info } from "std/log/mod.ts"; -import { getConfig } from "../app/config.ts"; +import { adminStore } from "../app/adminStore.ts"; import { generationStore } from "../app/generationStore.ts"; import { formatUserChat } from "../utils/formatUserChat.ts"; import { ErisContext } from "./mod.ts"; @@ -12,9 +12,9 @@ export async function broadcastCommand(ctx: CommandContext) { return ctx.reply("I don't know who you are."); } - const config = await getConfig(); + const [admin] = await adminStore.getBy("tgUserId", { value: ctx.from.id }); - if (!config.adminUsernames.includes(ctx.from.username)) { + if (!admin) { return ctx.reply("Only a bot admin can use this command."); } diff --git a/bot/mod.ts b/bot/mod.ts index 4196e4e..b947640 100644 --- a/bot/mod.ts +++ b/bot/mod.ts @@ -4,7 +4,6 @@ import { hydrateReply, ParseModeFlavor } from "grammy_parse_mode"; import { run, sequentialize } from "grammy_runner"; import { error, info, warning } from "std/log/mod.ts"; import { sessions } from "../api/sessionsRoute.ts"; -import { getConfig, setConfig } from "../app/config.ts"; import { formatUserChat } from "../utils/formatUserChat.ts"; import { omitUndef } from "../utils/omitUndef.ts"; import { broadcastCommand } from "./broadcastCommand.ts"; @@ -170,30 +169,6 @@ bot.command("cancel", cancelCommand); bot.command("broadcast", broadcastCommand); -bot.command("pause", async (ctx) => { - if (!ctx.from?.username) return; - const config = await getConfig(); - if (!config.adminUsernames.includes(ctx.from.username)) return; - if (config.pausedReason != null) { - return ctx.reply(`Already paused: ${config.pausedReason}`); - } - await setConfig({ - pausedReason: ctx.match || "No reason given", - }); - warning(`Bot paused by ${ctx.from.first_name} because ${config.pausedReason}`); - return ctx.reply("Paused"); -}); - -bot.command("resume", async (ctx) => { - if (!ctx.from?.username) return; - const config = await getConfig(); - if (!config.adminUsernames.includes(ctx.from.username)) return; - if (config.pausedReason == null) return ctx.reply("Already running"); - await setConfig({ pausedReason: null }); - info(`Bot resumed by ${ctx.from.first_name}`); - return ctx.reply("Resumed"); -}); - bot.command("crash", () => { throw new Error("Crash command used"); }); diff --git a/deno.json b/deno.json index 51a8c28..b23b395 100644 --- a/deno.json +++ b/deno.json @@ -45,8 +45,7 @@ "t_rest/server": "https://esm.sh/ty-rest@0.4.1/server", "twind/core": "https://esm.sh/@twind/core@1.1.3", "twind/preset-tailwind": "https://esm.sh/@twind/preset-tailwind@1.1.4", - "ulid": "https://deno.land/x/ulid@v0.3.0/mod.ts", - "use-local-storage": "https://esm.sh/use-local-storage@3.0.0?external=react&dev" + "ulid": "https://deno.land/x/ulid@v0.3.0/mod.ts" }, "lint": { "rules": { diff --git a/ui/AdminsPage.tsx b/ui/AdminsPage.tsx new file mode 100644 index 0000000..aa98707 --- /dev/null +++ b/ui/AdminsPage.tsx @@ -0,0 +1,250 @@ +import React, { useRef } from "react"; +import useSWR, { useSWRConfig } from "swr"; +import { AdminData } from "../api/adminsRoute.ts"; +import { fetchApi, handleResponse } from "./apiClient.ts"; + +export function AdminsPage(props: { sessionId: string | null }) { + const { sessionId } = props; + + const { mutate } = useSWRConfig(); + + const addDialogRef = useRef(null); + + const getSession = useSWR( + sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null, + (args) => fetchApi(...args).then(handleResponse), + ); + const getUser = useSWR( + getSession.data?.userId + ? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const + : null, + (args) => fetchApi(...args).then(handleResponse), + ); + + const getAdmins = useSWR( + ["admins", "GET", {}] as const, + (args) => fetchApi(...args).then(handleResponse), + ); + + return ( + <> + {getUser.data && getAdmins.data && getAdmins.data.length === 0 && ( + + )} + + {getAdmins.data?.length + ? ( +
    + {getAdmins.data.map((admin) => ( + + ))} +
+ ) + : getAdmins.data?.length === 0 + ?

No admins

+ : getAdmins.error + ?

Loading admins failed

+ :
} + + {getUser.data?.admin && ( + + )} + + + + ); +} + +function AddAdminDialog(props: { + dialogRef: React.RefObject; + sessionId: string | null; +}) { + const { dialogRef, sessionId } = props; + + const { mutate } = useSWRConfig(); + + return ( + +
{ + e.preventDefault(); + const data = new FormData(e.target as HTMLFormElement); + mutate( + (key) => Array.isArray(key) && key[0] === "admins", + async () => + fetchApi("admins", "POST", { + query: { sessionId: sessionId! }, + body: { + type: "application/json", + data: { + tgUserId: Number(data.get("tgUserId") as string), + }, + }, + }).then(handleResponse), + { populateCache: false }, + ); + dialogRef.current?.close(); + }} + > + + +
+ + +
+
+
+ ); +} + +function AdminListItem(props: { admin: AdminData; sessionId: string | null }) { + const { admin, sessionId } = props; + + const deleteDialogRef = useRef(null); + + const getAdminUser = useSWR( + ["users/{userId}", "GET", { params: { userId: String(admin.tgUserId) } }] as const, + (args) => fetchApi(...args).then(handleResponse), + ); + + const getSession = useSWR( + sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null, + (args) => fetchApi(...args).then(handleResponse), + ); + const getUser = useSWR( + getSession.data?.userId + ? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const + : null, + (args) => fetchApi(...args).then(handleResponse), + ); + + return ( +
  • +

    + {getAdminUser.data?.first_name ?? admin.id} {getAdminUser.data?.last_name}{" "} + {getAdminUser.data?.username + ? ( + + @{getAdminUser.data.username} + + ) + : null} +

    + {getAdminUser.data?.bio + ? ( +

    + {getAdminUser.data?.bio} +

    + ) + : null} + {getUser.data?.admin && ( +

    + +

    + )} + +
  • + ); +} + +function DeleteAdminDialog(props: { + dialogRef: React.RefObject; + adminId: string; + sessionId: string | null; +}) { + const { dialogRef, adminId, sessionId } = props; + const { mutate } = useSWRConfig(); + + return ( + +
    { + e.preventDefault(); + mutate( + (key) => Array.isArray(key) && key[0] === "admins", + async () => + fetchApi("admins/{adminId}", "DELETE", { + query: { sessionId: sessionId! }, + params: { adminId: adminId }, + }).then(handleResponse), + { populateCache: false }, + ); + dialogRef.current?.close(); + }} + > +

    + Are you sure you want to delete this admin? +

    +
    + + +
    +
    +
    + ); +} diff --git a/ui/App.tsx b/ui/App.tsx index a1d1466..48dbd0d 100644 --- a/ui/App.tsx +++ b/ui/App.tsx @@ -1,16 +1,17 @@ import React, { useEffect } from "react"; import { Route, Routes } from "react-router-dom"; -import useLocalStorage from "use-local-storage"; +import { AdminsPage } from "./AdminsPage.tsx"; import { AppHeader } from "./AppHeader.tsx"; import { QueuePage } from "./QueuePage.tsx"; import { SettingsPage } from "./SettingsPage.tsx"; import { StatsPage } from "./StatsPage.tsx"; import { WorkersPage } from "./WorkersPage.tsx"; -import { fetchApi, handleResponse } from "./apiClient.tsx"; +import { fetchApi, handleResponse } from "./apiClient.ts"; +import { useLocalStorage } from "./useLocalStorage.ts"; export function App() { // store session ID in the local storage - const [sessionId, setSessionId] = useLocalStorage("sessionId", ""); + const [sessionId, setSessionId] = useLocalStorage("sessionId"); // initialize a new session when there is no session ID useEffect(() => { @@ -27,11 +28,12 @@ export function App() { setSessionId("")} + onLogOut={() => setSessionId(null)} />
    } /> + } /> } /> } /> } /> diff --git a/ui/AppHeader.tsx b/ui/AppHeader.tsx index ef63a66..96a2cad 100644 --- a/ui/AppHeader.tsx +++ b/ui/AppHeader.tsx @@ -2,7 +2,7 @@ import React, { ReactNode } from "react"; import { NavLink } from "react-router-dom"; import useSWR from "swr"; import { cx } from "twind/core"; -import { fetchApi, handleResponse } from "./apiClient.tsx"; +import { fetchApi, handleResponse } from "./apiClient.ts"; function NavTab(props: { to: string; children: ReactNode }) { return ( @@ -15,33 +15,35 @@ function NavTab(props: { to: string; children: ReactNode }) { ); } -export function AppHeader( - props: { className?: string; sessionId?: string; onLogOut: () => void }, -) { +export function AppHeader(props: { + className?: string; + sessionId: string | null; + onLogOut: () => void; +}) { const { className, sessionId, onLogOut } = props; - const session = useSWR( + const getSession = useSWR( sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null, (args) => fetchApi(...args).then(handleResponse), { onError: () => onLogOut() }, ); - const user = useSWR( - session.data?.userId - ? ["users/{userId}", "GET", { params: { userId: String(session.data.userId) } }] as const + const getUser = useSWR( + getSession.data?.userId + ? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const : null, (args) => fetchApi(...args).then(handleResponse), ); - const bot = useSWR( + const getBot = useSWR( ["bot", "GET", {}] as const, (args) => fetchApi(...args).then(handleResponse), ); - const userPhoto = useSWR( - session.data?.userId + const getUserPhoto = useSWR( + getSession.data?.userId ? ["users/{userId}/photo", "GET", { - params: { userId: String(session.data.userId) }, + params: { userId: String(getSession.data.userId) }, }] as const : null, (args) => fetchApi(...args).then(handleResponse).then((blob) => URL.createObjectURL(blob)), @@ -62,6 +64,9 @@ export function AppHeader( Stats + + Admins + Workers @@ -74,29 +79,29 @@ export function AppHeader( {/* loading indicator */} - {session.isLoading || user.isLoading ?
    : null} + {getSession.isLoading || getUser.isLoading ?
    : null} {/* user avatar */} - {user.data - ? userPhoto.data + {getUser.data + ? getUserPhoto.data ? ( avatar ) : (
    - {user.data.first_name.at(0)?.toUpperCase()} + {getUser.data.first_name.at(0)?.toUpperCase()}
    ) : null} {/* login/logout button */} - {!session.isLoading && !user.isLoading && bot.data && sessionId + {!getSession.isLoading && !getUser.isLoading && getBot.data && sessionId ? ( - user.data + getUser.data ? (