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..21847aa --- /dev/null +++ b/server/routes/api/admin/users/[id].patch.ts @@ -0,0 +1,116 @@ +import { eq } from "drizzle-orm"; +import { defineEventHandler, getRouterParam, readBody } from "h3"; +import { db } from "@/db"; +import { mailboxTokens, users } from "@/db/schema"; +import { + authenticateEvent, + clearAuthCache, + deregisterMailbox, + registerMailbox, +} from "@/lib/auth"; + +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) { + 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; + } + + 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" }, + }); + } + + // 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(); + } + } + + return { user: updated }; +}); diff --git a/server/routes/api/auth/register.post.ts b/server/routes/api/auth/register.post.ts index ed7a129..14947ad 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,17 @@ 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/server/routes/api/chat/channels.get.ts b/server/routes/api/chat/channels.get.ts index 10bc606..640e0e3 100644 --- a/server/routes/api/chat/channels.get.ts +++ b/server/routes/api/chat/channels.get.ts @@ -1,4 +1,4 @@ -import { defineEventHandler } from "h3"; +import { defineEventHandler, getQuery } from "h3"; import { authenticateEvent } from "@/lib/auth"; import { listChannels } from "@/lib/chat"; @@ -11,6 +11,9 @@ export default defineEventHandler(async (event) => { }); } - const channels = await listChannels(auth.identity); + const query = getQuery(event); + const archived = query.archived === "true"; + + const channels = await listChannels(auth.identity, { archived }); return { channels }; }); diff --git a/server/routes/api/chat/channels/[id]/archive.post.ts b/server/routes/api/chat/channels/[id]/archive.post.ts new file mode 100644 index 0000000..8f7bff1 --- /dev/null +++ b/server/routes/api/chat/channels/[id]/archive.post.ts @@ -0,0 +1,37 @@ +import { defineEventHandler, getRouterParam } from "h3"; +import { authenticateEvent } from "@/lib/auth"; +import { archiveChannel, isMember } from "@/lib/chat"; + +/** + * POST /api/chat/channels/:id/archive + * Soft-deletes this channel from the current user's view. + * Other members are unaffected. + */ +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" }, + }); + } + + const channelId = getRouterParam(event, "id"); + if (!channelId) { + return new Response(JSON.stringify({ error: "Channel ID required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const member = await isMember(channelId, auth.identity); + if (!member) { + return new Response(JSON.stringify({ error: "Not a member" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + } + + await archiveChannel(channelId, auth.identity); + return { ok: true }; +}); diff --git a/server/routes/api/chat/channels/[id]/unarchive.post.ts b/server/routes/api/chat/channels/[id]/unarchive.post.ts new file mode 100644 index 0000000..f96e128 --- /dev/null +++ b/server/routes/api/chat/channels/[id]/unarchive.post.ts @@ -0,0 +1,36 @@ +import { defineEventHandler, getRouterParam } from "h3"; +import { authenticateEvent } from "@/lib/auth"; +import { isMember, unarchiveChannel } from "@/lib/chat"; + +/** + * POST /api/chat/channels/:id/unarchive + * Restores a previously archived channel for the current user. + */ +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" }, + }); + } + + const channelId = getRouterParam(event, "id"); + if (!channelId) { + return new Response(JSON.stringify({ error: "Channel ID required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const member = await isMember(channelId, auth.identity); + if (!member) { + return new Response(JSON.stringify({ error: "Not a member" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + } + + await unarchiveChannel(channelId, auth.identity); + return { ok: true }; +}); diff --git a/server/routes/api/users.get.ts b/server/routes/api/users.get.ts index c69da16..905161e 100644 --- a/server/routes/api/users.get.ts +++ b/server/routes/api/users.get.ts @@ -1,9 +1,12 @@ -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, 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); if (!auth) { @@ -13,23 +16,23 @@ 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 dbUsers = await listUsers(); + const dbIds = new Set(dbUsers.map((u) => u.id)); - const identities = new Set(dbTokens.map((t) => t.identity)); + // 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, + })); - // 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() }; + return { users: [...dbUsers, ...envOnly] }; }); diff --git a/src/components/compose-dialog.tsx b/src/components/compose-dialog.tsx index a3a6c9b..5c94863 100644 --- a/src/components/compose-dialog.tsx +++ b/src/components/compose-dialog.tsx @@ -10,8 +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"; - -const KNOWN_RECIPIENTS = ["chris", "clio", "domingo", "zumie"]; +import { useUserIds } from "@/lib/use-users"; export function ComposeDialog({ open, @@ -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) => { @@ -348,7 +400,7 @@ function PresenceView({ onLogout }: { onLogout: () => void }) { return (
void }) {

{name}

- {ch.last_message && ( - - {formatMessageTime(ch.last_message.created_at)} - - )} +
+ {ch.last_message && ( + + {formatMessageTime(ch.last_message.created_at)} + + )} + +

@@ -747,6 +817,7 @@ function NewGroupDialog({ onOpenChange: (open: boolean) => void; onCreated: (channelId: string) => void; }) { + const allUserIds = useUserIds(); const [name, setName] = useState(""); const [selected, setSelected] = useState>(new Set()); const [creating, setCreating] = useState(false); @@ -793,7 +864,7 @@ function NewGroupDialog({

Members

- {ALL_USERS.map((user) => ( + {allUserIds.map((user) => (