Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions server/routes/api/admin/users.get.ts
Original file line number Diff line number Diff line change
@@ -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 };
});
116 changes: 116 additions & 0 deletions server/routes/api/admin/users/[id].patch.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>(event);
const body: UserPatchBody = raw ?? {};

const patch: Partial<typeof users.$inferInsert> & { 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));
Comment on lines +103 to +105

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Block archived users with env-backed tokens

Archiving currently revokes only mailbox_tokens rows, but auth still accepts identities from envTokens (HIVE_TOKEN_*, MAILBOX_TOKEN_*, UI_MAILBOX_KEYS) and sender routes do not re-check sender validity against mailbox status, so an archived user with an env-configured token can continue authenticating and calling protected APIs after archival. To make deactivation effective, archival needs to block env-token auth for that identity (or auth must check users.archived_at before returning a context).

Useful? React with πŸ‘Β / πŸ‘Ž.

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 };
});
13 changes: 12 additions & 1 deletion server/routes/api/auth/register.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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();
Expand Down
7 changes: 5 additions & 2 deletions server/routes/api/chat/channels.get.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { defineEventHandler } from "h3";
import { defineEventHandler, getQuery } from "h3";
import { authenticateEvent } from "@/lib/auth";
import { listChannels } from "@/lib/chat";

Expand All @@ -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 };
});
37 changes: 37 additions & 0 deletions server/routes/api/chat/channels/[id]/archive.post.ts
Original file line number Diff line number Diff line change
@@ -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 };
});
36 changes: 36 additions & 0 deletions server/routes/api/chat/channels/[id]/unarchive.post.ts
Original file line number Diff line number Diff line change
@@ -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 };
});
45 changes: 24 additions & 21 deletions server/routes/api/users.get.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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) => ({
Comment on lines +24 to +25

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude archived env identities from /api/users

The env fallback compares against dbIds built from listUsers() (non-archived only), so if an identity is archived in users but still present in env tokens, it is reintroduced as an active user object here. That makes archived accounts reappear in user pickers and causes downstream send/archive UX inconsistencies because those accounts were intentionally deactivated. The merge should exclude any identity that exists in users at all, not just currently active ones.

Useful? React with πŸ‘Β / πŸ‘Ž.

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] };
});
6 changes: 3 additions & 3 deletions src/components/compose-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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("");
Expand Down Expand Up @@ -70,7 +70,7 @@ export function ComposeDialog({
<div className="space-y-2">
<Label>To</Label>
<div className="flex flex-wrap gap-1.5 mb-2">
{KNOWN_RECIPIENTS.map((r) => (
{knownRecipients.map((r) => (
<Button
key={r}
type="button"
Expand Down
Loading