-
Notifications
You must be signed in to change notification settings - Fork 0
Release v1.0.3: Multi-user support, dynamic user registry, chat archive #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e21f9b6
cf70ce1
e42740a
144f1b3
d2edc01
c22f9f1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 }; | ||
| }); |
| 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)); | ||
| clearAuthCache(); | ||
| } else { | ||
| // Restore: re-add to valid mailboxes; revoked tokens remain revoked | ||
| // (admin must issue new tokens via invite or token endpoint) | ||
| registerMailbox(id); | ||
ChrisCompton marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| clearAuthCache(); | ||
| } | ||
| } | ||
|
|
||
| return { user: updated }; | ||
| }); | ||
| 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 }; | ||
| }); |
| 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 }; | ||
| }); |
| 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) { | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The env fallback compares against 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] }; | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Archiving currently revokes only
mailbox_tokensrows, but auth still accepts identities fromenvTokens(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 checkusers.archived_atbefore returning a context).Useful? React with πΒ / π.