From e21f9b6ff7b39b1d8ac4821dc0c2a137f7ef341c Mon Sep 17 00:00:00 2001 From: Domingo Date: Sat, 21 Feb 2026 10:05:55 -0600 Subject: [PATCH 1/6] feat: add central users table with admin management and auto-registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add users table (id, displayName, isAdmin, isAgent, avatarUrl, timestamps) - Apply DB migration: CREATE TABLE + GRANT to team_user - Seed initial users: chris, clio, domingo, zumie, gonzo - auth.ts: remove hardcoded validMailboxes set, load from users table at startup - auth.ts: export listUsers() for admin use - register.post.ts: auto-upsert users row on invite redemption - Add GET /api/admin/users — list users with presence enrichment - Add PATCH /api/admin/users/:id — update user profile/status - api.ts: add listUsers() and updateUser() client helpers --- server/routes/api/admin/users.get.ts | 39 +++++++++++ server/routes/api/admin/users/[id].patch.ts | 72 +++++++++++++++++++++ server/routes/api/auth/register.post.ts | 10 ++- src/db/schema.ts | 17 +++++ src/lib/api.ts | 8 +++ src/lib/auth.ts | 30 ++++++++- 6 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 server/routes/api/admin/users.get.ts create mode 100644 server/routes/api/admin/users/[id].patch.ts diff --git a/server/routes/api/admin/users.get.ts b/server/routes/api/admin/users.get.ts new file mode 100644 index 0000000..7c5e660 --- /dev/null +++ b/server/routes/api/admin/users.get.ts @@ -0,0 +1,39 @@ +import { defineEventHandler } from "h3"; +import { authenticateEvent, listUsers } from "@/lib/auth"; +import { getPresence } from "@/lib/presence"; + +/** + * GET /api/admin/users + * Returns all active users enriched with presence data. + * Admin only. + */ +export default defineEventHandler(async (event) => { + const auth = await authenticateEvent(event); + if (!auth) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + if (!auth.isAdmin) { + return new Response(JSON.stringify({ error: "Admin required" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + } + + const [userRows, presence] = await Promise.all([listUsers(), getPresence()]); + + const enriched = userRows.map((user) => { + const p = presence[user.id]; + return { + ...user, + online: p?.online ?? false, + lastSeen: p?.lastSeen ?? null, + presenceSource: p?.source ?? null, + }; + }); + + return { users: enriched }; +}); diff --git a/server/routes/api/admin/users/[id].patch.ts b/server/routes/api/admin/users/[id].patch.ts new file mode 100644 index 0000000..23da083 --- /dev/null +++ b/server/routes/api/admin/users/[id].patch.ts @@ -0,0 +1,72 @@ +import { defineEventHandler, readBody, getRouterParam } from "h3"; +import { authenticateEvent } from "@/lib/auth"; +import { db } from "@/db"; +import { users } from "@/db/schema"; +import { eq } from "drizzle-orm"; + +interface UserPatchBody { + displayName?: string; + isAdmin?: boolean; + isAgent?: boolean; + avatarUrl?: string | null; + archivedAt?: string | null; +} + +/** + * PATCH /api/admin/users/:id + * Update a user's profile. Admin only. + */ +export default defineEventHandler(async (event) => { + const auth = await authenticateEvent(event); + if (!auth) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + if (!auth.isAdmin) { + return new Response(JSON.stringify({ error: "Admin required" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + } + + const id = getRouterParam(event, "id"); + if (!id) { + return new Response(JSON.stringify({ error: "User ID required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const raw = await readBody>(event); + const body: UserPatchBody = raw ?? {}; + + const patch: Partial & { updatedAt: Date } = { + updatedAt: new Date(), + }; + + if ("displayName" in body && body.displayName !== undefined) patch.displayName = String(body.displayName); + if ("isAdmin" in body && body.isAdmin !== undefined) patch.isAdmin = Boolean(body.isAdmin); + if ("isAgent" in body && body.isAgent !== undefined) patch.isAgent = Boolean(body.isAgent); + if ("avatarUrl" in body) patch.avatarUrl = body.avatarUrl ?? null; + if ("archivedAt" in body) { + patch.archivedAt = body.archivedAt ? new Date(body.archivedAt) : null; + } + + const [updated] = await db + .update(users) + .set(patch) + .where(eq(users.id, id)) + .returning(); + + if (!updated) { + return new Response(JSON.stringify({ error: "User not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + return { user: updated }; +}); diff --git a/server/routes/api/auth/register.post.ts b/server/routes/api/auth/register.post.ts index ed7a129..46e07a8 100644 --- a/server/routes/api/auth/register.post.ts +++ b/server/routes/api/auth/register.post.ts @@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto"; import { and, eq, gt, isNull, or } from "drizzle-orm"; import { defineEventHandler, readBody } from "h3"; import { db } from "@/db"; -import { invites, mailboxTokens } from "@/db/schema"; +import { invites, mailboxTokens, users } from "@/db/schema"; import { clearAuthCache, registerMailbox } from "@/lib/auth"; export default defineEventHandler(async (event) => { @@ -85,6 +85,14 @@ export default defineEventHandler(async (event) => { .set({ useCount: invite.useCount + 1 }) .where(eq(invites.id, invite.id)); + // Ensure a users row exists for this identity + await db.insert(users).values({ + id: identity, + displayName: body.displayName || identity, + isAdmin: invite.isAdmin, + isAgent: false, // humans registering via invite + }).onConflictDoNothing(); + // Register the mailbox and clear auth cache registerMailbox(identity); clearAuthCache(); diff --git a/src/db/schema.ts b/src/db/schema.ts index 0aeb8ec..da7e4c4 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -455,6 +455,22 @@ export const attachments = pgTable( index("idx_attachments_entity").on(table.entityType, table.entityId), ], ); +// ============================================================ +// USERS (central registry) +// ============================================================ + +export const users = pgTable("users", { + id: varchar("id", { length: 50 }).primaryKey(), // = identity slug ("gonzo", "chris") + displayName: varchar("display_name", { length: 100 }).notNull(), + isAdmin: boolean("is_admin").notNull().default(false), + isAgent: boolean("is_agent").notNull().default(false), // true for AI agents + avatarUrl: text("avatar_url"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + lastSeenAt: timestamp("last_seen_at", { withTimezone: true }), + archivedAt: timestamp("archived_at", { withTimezone: true }), // soft-deactivate +}); + // ============================================================ // TYPES @@ -479,3 +495,4 @@ export type NotebookPage = typeof notebookPages.$inferSelect; export type SwarmTaskNotebookPage = typeof swarmTaskNotebookPages.$inferSelect; export type Attachment = typeof attachments.$inferSelect; export type ContentProjectTag = typeof contentProjectTags.$inferSelect; +export type User = typeof users.$inferSelect; diff --git a/src/lib/api.ts b/src/lib/api.ts index fa71ee7..ee9e398 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -306,6 +306,14 @@ export const api = { // Admin getUserStats: () => apiFetch("/admin/user-stats"), + listUsers: () => apiFetch("/admin/users"), + + updateUser: (id: string, data: Record) => + apiFetch(`/admin/users/${id}`, { + method: "PATCH", + body: JSON.stringify(data), + }), + searchChatMessages: (params: { q: string; channelId?: string; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 2089dea..dedeafc 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -3,7 +3,7 @@ import { and, eq, gt, isNull, or } from "drizzle-orm"; import type { H3Event } from "h3"; import { getHeader } from "h3"; import { db } from "@/db"; -import { mailboxTokens } from "@/db/schema"; +import { mailboxTokens, users } from "@/db/schema"; config({ path: ".env" }); config({ path: "/etc/clawdbot/vault.env" }); @@ -22,6 +22,31 @@ const validMailboxes = new Set(); const dbCache = new Map(); const DB_CACHE_TTL = 30_000; // 30 seconds +/** Load all active users from DB into validMailboxes at startup */ +async function loadUsersFromDb() { + try { + const rows = await db + .select({ id: users.id }) + .from(users) + .where(isNull(users.archivedAt)); + for (const row of rows) { + validMailboxes.add(row.id); + } + console.log(`[auth] Loaded ${rows.length} user(s) from DB into validMailboxes`); + } catch (err) { + console.error("[auth] Failed to load users from DB:", err); + } +} + +/** Return all non-archived users ordered by display name */ +export async function listUsers() { + return db + .select() + .from(users) + .where(isNull(users.archivedAt)) + .orderBy(users.displayName); +} + export function isValidMailbox(name: string): boolean { return validMailboxes.has(name); } @@ -185,6 +210,9 @@ export function initAuth() { } console.log(`[auth] Loaded ${envTokens.size} env token(s), DB auth enabled`); + + // Load known users from DB into validMailboxes (fire-and-forget) + loadUsersFromDb().catch(err => console.error("[auth] Failed to load users from DB:", err)); } /** Clear the DB token cache (e.g., after creating/revoking tokens) */ From cf70ce14c6b13acb1666b0a623f2105f00f504a5 Mon Sep 17 00:00:00 2001 From: Domingo Date: Sat, 21 Feb 2026 10:27:06 -0600 Subject: [PATCH 2/6] feat: replace hardcoded user lists with dynamic users API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GET /api/users — authenticated endpoint returning all active users - Add useUsers() / useUserIds() React hook with 5-min module-level cache - Replace ALL_USERS/KNOWN_USERS/KNOWN_RECIPIENTS constants in: - src/components/nav.tsx (presence avatar bar) - src/routes/presence.tsx (team list, group chat dialog) - src/routes/swarm.tsx (task assignee pickers, project lead pickers) - src/routes/admin.tsx (recurring task assignee picker) - src/components/compose-dialog.tsx (recipient quick-pick) - Any new user added to the users table now automatically appears everywhere --- server/routes/api/users.get.ts | 30 +++++---------------- src/components/compose-dialog.tsx | 6 ++--- src/components/nav.tsx | 6 ++--- src/lib/api.ts | 2 ++ src/lib/use-users.ts | 44 +++++++++++++++++++++++++++++++ src/routes/admin.tsx | 9 ++++--- src/routes/presence.tsx | 9 ++++--- src/routes/swarm.tsx | 23 +++++++++------- 8 files changed, 85 insertions(+), 44 deletions(-) create mode 100644 src/lib/use-users.ts diff --git a/server/routes/api/users.get.ts b/server/routes/api/users.get.ts index c69da16..239b571 100644 --- a/server/routes/api/users.get.ts +++ b/server/routes/api/users.get.ts @@ -1,9 +1,10 @@ -import { isNull } from "drizzle-orm"; import { defineEventHandler } from "h3"; -import { db } from "@/db"; -import { mailboxTokens } from "@/db/schema"; -import { authenticateEvent } from "@/lib/auth"; +import { authenticateEvent, listUsers } from "@/lib/auth"; +/** + * GET /api/users + * Returns all active users. Requires authentication (not admin-only). + */ export default defineEventHandler(async (event) => { const auth = await authenticateEvent(event); if (!auth) { @@ -13,23 +14,6 @@ export default defineEventHandler(async (event) => { }); } - // Get identities from DB tokens (non-revoked) - const dbTokens = await db - .select({ identity: mailboxTokens.identity }) - .from(mailboxTokens) - .where(isNull(mailboxTokens.revokedAt)); - - const identities = new Set(dbTokens.map((t) => t.identity)); - - // Also include env token identities - for (const key of Object.keys(process.env)) { - if (key.startsWith("MAILBOX_TOKEN_")) { - identities.add(key.replace("MAILBOX_TOKEN_", "").toLowerCase()); - } - } - - // Remove the admin token key if it slipped in - identities.delete("admin"); - - return { users: [...identities].sort() }; + const users = await listUsers(); + return { users }; }); diff --git a/src/components/compose-dialog.tsx b/src/components/compose-dialog.tsx index a3a6c9b..0425f2f 100644 --- a/src/components/compose-dialog.tsx +++ b/src/components/compose-dialog.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useUserIds } from "@/lib/use-users"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -11,8 +12,6 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { api } from "@/lib/api"; -const KNOWN_RECIPIENTS = ["chris", "clio", "domingo", "zumie"]; - export function ComposeDialog({ open, onOpenChange, @@ -22,6 +21,7 @@ export function ComposeDialog({ onOpenChange: (open: boolean) => void; onSent: () => void; }) { + const knownRecipients = useUserIds(); const [recipient, setRecipient] = useState(""); const [title, setTitle] = useState(""); const [body, setBody] = useState(""); @@ -70,7 +70,7 @@ export function ComposeDialog({
- {KNOWN_RECIPIENTS.map((r) => ( + {knownRecipients.map((r) => ( +
+ {!showArchived && ( + + )} + +
{channels.length === 0 && (

- No chats yet. Click a team member to start. + {showArchived ? "No archived chats." : "No chats yet. Click a team member to start."}

)} {channels.map((ch) => { @@ -350,12 +392,14 @@ function PresenceView({ onLogout }: { onLogout: () => void }) { return (
setActiveChannel(ch.id)} + onMouseEnter={() => setHoveredChannel(ch.id)} + onMouseLeave={() => setHoveredChannel(null)} > {isGroup ? (
@@ -379,11 +423,25 @@ function PresenceView({ onLogout }: { onLogout: () => void }) {

{name}

- {ch.last_message && ( - - {formatMessageTime(ch.last_message.created_at)} - - )} +
+ {hoveredChannel === ch.id ? ( + + ) : ch.last_message ? ( + + {formatMessageTime(ch.last_message.created_at)} + + ) : null} +

From 144f1b3ac9e0f817bde00c11b383b4054745d9d4 Mon Sep 17 00:00:00 2001 From: Domingo Date: Sat, 21 Feb 2026 12:13:58 -0600 Subject: [PATCH 4/6] fix: address Codex P1/P2 findings on users API and admin patch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: user-select.tsx was spreading data.users (now HiveUser[]) directly into a string[] — map to ids before merging with value set P2: Boolean() coercion in admin PATCH endpoint would accept any non-empty string as true for isAdmin/isAgent flags — now requires strict boolean type, returns 400 on anything else --- server/routes/api/admin/users/[id].patch.ts | 33 +++++++++-- server/routes/api/auth/register.post.ts | 15 +++-- .../api/chat/channels/[id]/unarchive.post.ts | 2 +- src/components/compose-dialog.tsx | 2 +- src/components/nav.tsx | 30 +++++----- src/components/user-select.tsx | 6 +- src/db/schema.ts | 9 ++- src/lib/auth.ts | 8 ++- src/lib/chat.ts | 5 +- src/routes/presence.tsx | 58 ++++++++++++------- 10 files changed, 111 insertions(+), 57 deletions(-) diff --git a/server/routes/api/admin/users/[id].patch.ts b/server/routes/api/admin/users/[id].patch.ts index 23da083..42ba64e 100644 --- a/server/routes/api/admin/users/[id].patch.ts +++ b/server/routes/api/admin/users/[id].patch.ts @@ -1,8 +1,8 @@ -import { defineEventHandler, readBody, getRouterParam } from "h3"; -import { authenticateEvent } from "@/lib/auth"; +import { eq } from "drizzle-orm"; +import { defineEventHandler, getRouterParam, readBody } from "h3"; import { db } from "@/db"; import { users } from "@/db/schema"; -import { eq } from "drizzle-orm"; +import { authenticateEvent } from "@/lib/auth"; interface UserPatchBody { displayName?: string; @@ -47,9 +47,30 @@ export default defineEventHandler(async (event) => { updatedAt: new Date(), }; - if ("displayName" in body && body.displayName !== undefined) patch.displayName = String(body.displayName); - if ("isAdmin" in body && body.isAdmin !== undefined) patch.isAdmin = Boolean(body.isAdmin); - if ("isAgent" in body && body.isAgent !== undefined) patch.isAgent = Boolean(body.isAgent); + if ("displayName" in body && body.displayName !== undefined) + patch.displayName = String(body.displayName); + if ("isAdmin" in body && body.isAdmin !== undefined) { + if (typeof body.isAdmin !== "boolean") + return new Response( + JSON.stringify({ error: "isAdmin must be a boolean" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + patch.isAdmin = body.isAdmin; + } + if ("isAgent" in body && body.isAgent !== undefined) { + if (typeof body.isAgent !== "boolean") + return new Response( + JSON.stringify({ error: "isAgent must be a boolean" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + patch.isAgent = body.isAgent; + } if ("avatarUrl" in body) patch.avatarUrl = body.avatarUrl ?? null; if ("archivedAt" in body) { patch.archivedAt = body.archivedAt ? new Date(body.archivedAt) : null; diff --git a/server/routes/api/auth/register.post.ts b/server/routes/api/auth/register.post.ts index 46e07a8..14947ad 100644 --- a/server/routes/api/auth/register.post.ts +++ b/server/routes/api/auth/register.post.ts @@ -86,12 +86,15 @@ export default defineEventHandler(async (event) => { .where(eq(invites.id, invite.id)); // Ensure a users row exists for this identity - await db.insert(users).values({ - id: identity, - displayName: body.displayName || identity, - isAdmin: invite.isAdmin, - isAgent: false, // humans registering via invite - }).onConflictDoNothing(); + await db + .insert(users) + .values({ + id: identity, + displayName: body.displayName || identity, + isAdmin: invite.isAdmin, + isAgent: false, // humans registering via invite + }) + .onConflictDoNothing(); // Register the mailbox and clear auth cache registerMailbox(identity); diff --git a/server/routes/api/chat/channels/[id]/unarchive.post.ts b/server/routes/api/chat/channels/[id]/unarchive.post.ts index 9963990..f96e128 100644 --- a/server/routes/api/chat/channels/[id]/unarchive.post.ts +++ b/server/routes/api/chat/channels/[id]/unarchive.post.ts @@ -1,6 +1,6 @@ import { defineEventHandler, getRouterParam } from "h3"; import { authenticateEvent } from "@/lib/auth"; -import { unarchiveChannel, isMember } from "@/lib/chat"; +import { isMember, unarchiveChannel } from "@/lib/chat"; /** * POST /api/chat/channels/:id/unarchive diff --git a/src/components/compose-dialog.tsx b/src/components/compose-dialog.tsx index 0425f2f..5c94863 100644 --- a/src/components/compose-dialog.tsx +++ b/src/components/compose-dialog.tsx @@ -1,5 +1,4 @@ import { useState } from "react"; -import { useUserIds } from "@/lib/use-users"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -11,6 +10,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { api } from "@/lib/api"; +import { useUserIds } from "@/lib/use-users"; export function ComposeDialog({ open, diff --git a/src/components/nav.tsx b/src/components/nav.tsx index ea3eac6..8afd227 100644 --- a/src/components/nav.tsx +++ b/src/components/nav.tsx @@ -69,20 +69,22 @@ function PresenceDots() { return () => clearInterval(interval); }, []); - const users = allUsers.map((name) => ({ - name, - info: presence[name] || { - online: false, - lastSeen: null, - source: null, - unread: 0, - }, - })).sort((a, b) => { - if (a.info.online !== b.info.online) return a.info.online ? -1 : 1; - const aTime = a.info.lastSeen ? new Date(a.info.lastSeen).getTime() : 0; - const bTime = b.info.lastSeen ? new Date(b.info.lastSeen).getTime() : 0; - return bTime - aTime; - }); + const users = allUsers + .map((name) => ({ + name, + info: presence[name] || { + online: false, + lastSeen: null, + source: null, + unread: 0, + }, + })) + .sort((a, b) => { + if (a.info.online !== b.info.online) return a.info.online ? -1 : 1; + const aTime = a.info.lastSeen ? new Date(a.info.lastSeen).getTime() : 0; + const bTime = b.info.lastSeen ? new Date(b.info.lastSeen).getTime() : 0; + return bTime - aTime; + }); return (

diff --git a/src/components/user-select.tsx b/src/components/user-select.tsx index fa2beea..4cb265b 100644 --- a/src/components/user-select.tsx +++ b/src/components/user-select.tsx @@ -32,7 +32,11 @@ export function UserSelect({ value, onChange, className }: UserSelectProps) { }) .then((r) => r.json()) .then((data) => { - const all = new Set([...(data.users || []), ...value]); + // data.users is HiveUser[] — extract ids for this string-based picker + const ids: string[] = (data.users || []).map( + (u: { id: string }) => u.id, + ); + const all = new Set([...ids, ...value]); setUsers([...all].sort()); }) .catch(() => {}); diff --git a/src/db/schema.ts b/src/db/schema.ts index 1ede1c8..453c4d4 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -466,13 +466,16 @@ export const users = pgTable("users", { isAdmin: boolean("is_admin").notNull().default(false), isAgent: boolean("is_agent").notNull().default(false), // true for AI agents avatarUrl: text("avatar_url"), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), lastSeenAt: timestamp("last_seen_at", { withTimezone: true }), archivedAt: timestamp("archived_at", { withTimezone: true }), // soft-deactivate }); - // ============================================================ // TYPES // ============================================================ diff --git a/src/lib/auth.ts b/src/lib/auth.ts index dedeafc..a1c28b6 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -32,7 +32,9 @@ async function loadUsersFromDb() { for (const row of rows) { validMailboxes.add(row.id); } - console.log(`[auth] Loaded ${rows.length} user(s) from DB into validMailboxes`); + console.log( + `[auth] Loaded ${rows.length} user(s) from DB into validMailboxes`, + ); } catch (err) { console.error("[auth] Failed to load users from DB:", err); } @@ -212,7 +214,9 @@ export function initAuth() { console.log(`[auth] Loaded ${envTokens.size} env token(s), DB auth enabled`); // Load known users from DB into validMailboxes (fire-and-forget) - loadUsersFromDb().catch(err => console.error("[auth] Failed to load users from DB:", err)); + loadUsersFromDb().catch((err) => + console.error("[auth] Failed to load users from DB:", err), + ); } /** Clear the DB token cache (e.g., after creating/revoking tokens) */ diff --git a/src/lib/chat.ts b/src/lib/chat.ts index 50adadc..55a6270 100644 --- a/src/lib/chat.ts +++ b/src/lib/chat.ts @@ -63,7 +63,10 @@ export async function createGroupChannel( } /** List channels for a user with last message and unread count */ -export async function listChannels(identity: string, options: { archived?: boolean } = {}) { +export async function listChannels( + identity: string, + options: { archived?: boolean } = {}, +) { const archivedFilter = options.archived ? rawSql`m.archived_at IS NOT NULL` : rawSql`m.archived_at IS NULL`; diff --git a/src/routes/presence.tsx b/src/routes/presence.tsx index 2000973..2a522af 100644 --- a/src/routes/presence.tsx +++ b/src/routes/presence.tsx @@ -240,20 +240,22 @@ function PresenceView({ onLogout }: { onLogout: () => void }) { const totalUnread = channels.reduce((sum, ch) => sum + ch.unread_count, 0); - const users = allUserIds.map((name) => ({ - name, - info: presence[name] || { - online: false, - lastSeen: null, - source: null, - unread: 0, - }, - })).sort((a, b) => { - if (a.info.online !== b.info.online) return a.info.online ? -1 : 1; - const aTime = a.info.lastSeen ? new Date(a.info.lastSeen).getTime() : 0; - const bTime = b.info.lastSeen ? new Date(b.info.lastSeen).getTime() : 0; - return bTime - aTime; - }); + const users = allUserIds + .map((name) => ({ + name, + info: presence[name] || { + online: false, + lastSeen: null, + source: null, + unread: 0, + }, + })) + .sort((a, b) => { + if (a.info.online !== b.info.online) return a.info.online ? -1 : 1; + const aTime = a.info.lastSeen ? new Date(a.info.lastSeen).getTime() : 0; + const bTime = b.info.lastSeen ? new Date(b.info.lastSeen).getTime() : 0; + return bTime - aTime; + }); const getChannelName = (ch: ChatChannel): string => { if (ch.name) return ch.name; @@ -366,11 +368,16 @@ function PresenceView({ onLogout }: { onLogout: () => void }) { variant={showArchived ? "secondary" : "ghost"} size="sm" className="text-xs gap-1.5 shrink-0" - onClick={() => { setShowArchived(!showArchived); setActiveChannel(null); }} + onClick={() => { + setShowArchived(!showArchived); + setActiveChannel(null); + }} title={showArchived ? "Back to chats" : "Archived chats"} > {showArchived ? ( - <> Back + <> + Back + ) : ( )} @@ -378,7 +385,9 @@ function PresenceView({ onLogout }: { onLogout: () => void }) {
{channels.length === 0 && (

- {showArchived ? "No archived chats." : "No chats yet. Click a team member to start."} + {showArchived + ? "No archived chats." + : "No chats yet. Click a team member to start."}

)} {channels.map((ch) => { @@ -429,12 +438,17 @@ function PresenceView({ onLogout }: { onLogout: () => void }) { type="button" className="h-5 w-5 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors" title={showArchived ? "Unarchive" : "Archive"} - onClick={(e) => showArchived ? handleUnarchive(e, ch.id) : handleArchive(e, ch.id)} - > - {showArchived - ? - : + onClick={(e) => + showArchived + ? handleUnarchive(e, ch.id) + : handleArchive(e, ch.id) } + > + {showArchived ? ( + + ) : ( + + )} ) : ch.last_message ? ( From d2edc0175a5a4a42a3ea10bfa6a63edba3623aa3 Mon Sep 17 00:00:00 2001 From: Domingo Date: Sat, 21 Feb 2026 12:23:13 -0600 Subject: [PATCH 5/6] fix: address second round of Codex findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 (auth.ts): archived users stay in validMailboxes until restart - Add deregisterMailbox() to auth.ts - PATCH /api/admin/users/:id now calls deregister/registerMailbox immediately when archivedAt is set/cleared — no restart needed P2 (presence.tsx): hover-only archive button unreachable on touch - Remove hoveredChannel state and mouse enter/leave handlers - Archive icon always visible (text-muted-foreground/30, tap target) with hover:text-muted-foreground for desktop polish - Timestamp shown alongside archive button at all times --- server/routes/api/admin/users/[id].patch.ts | 15 +++++++- src/lib/auth.ts | 5 +++ src/routes/presence.tsx | 42 ++++++++++----------- 3 files changed, 38 insertions(+), 24 deletions(-) diff --git a/server/routes/api/admin/users/[id].patch.ts b/server/routes/api/admin/users/[id].patch.ts index 42ba64e..03e728d 100644 --- a/server/routes/api/admin/users/[id].patch.ts +++ b/server/routes/api/admin/users/[id].patch.ts @@ -2,7 +2,11 @@ import { eq } from "drizzle-orm"; import { defineEventHandler, getRouterParam, readBody } from "h3"; import { db } from "@/db"; import { users } from "@/db/schema"; -import { authenticateEvent } from "@/lib/auth"; +import { + authenticateEvent, + deregisterMailbox, + registerMailbox, +} from "@/lib/auth"; interface UserPatchBody { displayName?: string; @@ -89,5 +93,14 @@ export default defineEventHandler(async (event) => { }); } + // Sync in-memory mailbox set immediately — no restart required + if ("archivedAt" in patch) { + if (patch.archivedAt) { + deregisterMailbox(id); + } else { + registerMailbox(id); + } + } + return { user: updated }; }); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index a1c28b6..41990db 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -57,6 +57,11 @@ export function registerMailbox(name: string) { validMailboxes.add(name); } +/** Remove an identity from the valid mailbox set (call when archiving/deactivating a user) */ +export function deregisterMailbox(name: string) { + validMailboxes.delete(name); +} + /** Check DB for a valid token */ async function authenticateFromDb(token: string): Promise { // Check cache first diff --git a/src/routes/presence.tsx b/src/routes/presence.tsx index 2a522af..1fa02a1 100644 --- a/src/routes/presence.tsx +++ b/src/routes/presence.tsx @@ -159,7 +159,6 @@ function PresenceView({ onLogout }: { onLogout: () => void }) { const [groupDialogOpen, setGroupDialogOpen] = useState(false); const [chatEvent, setChatEvent] = useState(null); const [showArchived, setShowArchived] = useState(false); - const [hoveredChannel, setHoveredChannel] = useState(null); const myIdentity = useVerifiedIdentity(); // SSE for real-time chat events @@ -407,8 +406,6 @@ function PresenceView({ onLogout }: { onLogout: () => void }) { : "hover:bg-muted/50" }`} onClick={() => setActiveChannel(ch.id)} - onMouseEnter={() => setHoveredChannel(ch.id)} - onMouseLeave={() => setHoveredChannel(null)} > {isGroup ? (
@@ -432,29 +429,28 @@ function PresenceView({ onLogout }: { onLogout: () => void }) {

{name}

-
- {hoveredChannel === ch.id ? ( - - ) : ch.last_message ? ( +
+ {ch.last_message && ( {formatMessageTime(ch.last_message.created_at)} - ) : null} + )} +
From c22f9f11f5465247b65b9253c0607fe4ce3894ba Mon Sep 17 00:00:00 2001 From: Domingo Date: Sat, 21 Feb 2026 12:30:50 -0600 Subject: [PATCH 6/6] fix: address third round of Codex P1 findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 (users.get.ts): env-token users missing from /api/users - Export getEnvIdentities() from auth.ts - /api/users merges DB users + env-only identities as minimal objects so all authenticated users appear in UI selectors regardless of whether they have a users table row P1 (patch.ts): archived users kept working tokens - On archive: revoke all mailboxTokens rows for that identity + clear DB auth cache — existing tokens immediately stop working - On restore: re-add to validMailboxes + clear cache (revoked tokens stay revoked; admin must issue new tokens) --- server/routes/api/admin/users/[id].patch.ts | 14 ++++++++++-- server/routes/api/users.get.ts | 25 ++++++++++++++++++--- src/lib/auth.ts | 9 ++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/server/routes/api/admin/users/[id].patch.ts b/server/routes/api/admin/users/[id].patch.ts index 03e728d..21847aa 100644 --- a/server/routes/api/admin/users/[id].patch.ts +++ b/server/routes/api/admin/users/[id].patch.ts @@ -1,9 +1,10 @@ import { eq } from "drizzle-orm"; import { defineEventHandler, getRouterParam, readBody } from "h3"; import { db } from "@/db"; -import { users } from "@/db/schema"; +import { mailboxTokens, users } from "@/db/schema"; import { authenticateEvent, + clearAuthCache, deregisterMailbox, registerMailbox, } from "@/lib/auth"; @@ -93,12 +94,21 @@ export default defineEventHandler(async (event) => { }); } - // Sync in-memory mailbox set immediately — no restart required + // Sync in-memory mailbox set and token validity immediately if ("archivedAt" in patch) { if (patch.archivedAt) { + // Archive: remove from valid mailboxes and revoke all DB tokens deregisterMailbox(id); + await db + .update(mailboxTokens) + .set({ revokedAt: new Date() }) + .where(eq(mailboxTokens.identity, id)); + clearAuthCache(); } else { + // Restore: re-add to valid mailboxes; revoked tokens remain revoked + // (admin must issue new tokens via invite or token endpoint) registerMailbox(id); + clearAuthCache(); } } diff --git a/server/routes/api/users.get.ts b/server/routes/api/users.get.ts index 239b571..905161e 100644 --- a/server/routes/api/users.get.ts +++ b/server/routes/api/users.get.ts @@ -1,9 +1,11 @@ import { defineEventHandler } from "h3"; -import { authenticateEvent, listUsers } from "@/lib/auth"; +import { authenticateEvent, getEnvIdentities, listUsers } from "@/lib/auth"; /** * GET /api/users * Returns all active users. Requires authentication (not admin-only). + * Includes users from the users table AND env-token identities that haven't + * been backfilled yet (returned as minimal user objects). */ export default defineEventHandler(async (event) => { const auth = await authenticateEvent(event); @@ -14,6 +16,23 @@ export default defineEventHandler(async (event) => { }); } - const users = await listUsers(); - return { users }; + const dbUsers = await listUsers(); + const dbIds = new Set(dbUsers.map((u) => u.id)); + + // Include env-token identities not yet in the users table + const envOnly = getEnvIdentities() + .filter((id) => !dbIds.has(id)) + .map((id) => ({ + id, + displayName: id, + isAdmin: false, + isAgent: false, + avatarUrl: null, + createdAt: null, + updatedAt: null, + lastSeenAt: null, + archivedAt: null, + })); + + return { users: [...dbUsers, ...envOnly] }; }); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 41990db..ac7ce9d 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -62,6 +62,15 @@ export function deregisterMailbox(name: string) { validMailboxes.delete(name); } +/** Return all identities registered via env tokens (HIVE_TOKEN_* / MAILBOX_TOKEN_* / UI_MAILBOX_KEYS) */ +export function getEnvIdentities(): string[] { + const ids = new Set(); + for (const ctx of envTokens.values()) { + if (ctx.identity && ctx.identity !== "admin") ids.add(ctx.identity); + } + return [...ids].sort(); +} + /** Check DB for a valid token */ async function authenticateFromDb(token: string): Promise { // Check cache first