diff --git a/harness-tests/core/chat-access-admin.test.ts b/harness-tests/core/chat-access-admin.test.ts new file mode 100644 index 000000000..dcff1a956 --- /dev/null +++ b/harness-tests/core/chat-access-admin.test.ts @@ -0,0 +1,186 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { PgDialect } from 'drizzle-orm/pg-core' +import type { SQL } from 'drizzle-orm' + +const mocks = vi.hoisted(() => { + const rows: unknown[][] = [] + const filters: (SQL | undefined)[] = [] + return { + rows, + filters, + requireCapability: vi.fn(), + select: vi.fn(), + limit: vi.fn(), + offset: vi.fn(), + lock: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + createInvite: vi.fn(), + } +}) +vi.mock('@tanstack/react-start/server', () => ({ + getRequest: () => new Request('https://tanstack.com/admin/chat-access'), +})) +vi.mock('~/auth/index.server', () => ({ + getAuthGuards: () => ({ requireCapability: mocks.requireCapability }), +})) +vi.mock('~/chat/access.server', () => ({ + createChatInvite: mocks.createInvite, +})) +vi.mock('~/db/client', () => { + function query() { + const rows = mocks.rows.shift() ?? [] + const chain = { + from: () => chain, + innerJoin: () => chain, + leftJoin: () => chain, + where: (filter: SQL | undefined) => { + mocks.filters.push(filter) + return chain + }, + orderBy: () => chain, + limit: (size: number) => { + mocks.limit(size) + return chain + }, + offset: (offset: number) => { + mocks.offset(offset) + return chain + }, + returning: () => chain, + then: (resolve: (value: unknown[]) => unknown) => + Promise.resolve(rows).then(resolve), + } + return chain + } + const db = { + select: (...args: unknown[]) => { + mocks.select(...args) + return query() + }, + execute: mocks.lock, + insert: () => ({ + values: (values: unknown) => { + mocks.insert(values) + return { onConflictDoNothing: vi.fn() } + }, + }), + update: () => ({ + set: (values: unknown) => { + mocks.update(values) + return query() + }, + }), + transaction: (run: (tx: unknown) => unknown) => run(db), + } + return { db } +}) +import { + createChatInviteAdmin, + expireChatInviteAdmin, + grantChatAccessAdmin, + listChatInvitesAdmin, + listChatWaitlistAdmin, +} from '~/chat/access-admin.server' + +beforeEach(() => { + vi.clearAllMocks() + mocks.rows.length = 0 + mocks.filters.length = 0 + mocks.requireCapability.mockResolvedValue({ userId: 'admin' }) +}) + +it.each([ + { + name: 'waitlist', + run: () => listChatWaitlistAdmin({ page: 0, status: 'waiting' }), + }, + { + name: 'invites', + run: () => listChatInvitesAdmin({ page: 0, status: 'pending' }), + }, + { name: 'grant', run: () => grantChatAccessAdmin('person') }, + { name: 'create', run: () => createChatInviteAdmin() }, + { name: 'expire', run: () => expireChatInviteAdmin('hash') }, +])('requires admin before $name reads or writes', async ({ run }) => { + mocks.requireCapability.mockRejectedValue(new Error('Missing admin')) + await expect(run()).rejects.toThrow('Missing admin') + expect(mocks.requireCapability).toHaveBeenCalledWith( + expect.any(Request), + 'admin', + ) + expect(mocks.select).not.toHaveBeenCalled() + expect(mocks.insert).not.toHaveBeenCalled() + expect(mocks.update).not.toHaveBeenCalled() + expect(mocks.createInvite).not.toHaveBeenCalled() +}) + +it('bounds waitlist reads and sorts oldest requests first', async () => { + mocks.rows.push([], [{ total: 80 }]) + expect(await listChatWaitlistAdmin({ page: 2, status: 'waiting' })).toEqual({ + entries: [], + total: 80, + pageSize: 25, + }) + expect(mocks.limit).toHaveBeenCalledWith(25) + expect(mocks.offset).toHaveBeenCalledWith(50) + const filter = mocks.filters[0] + if (!filter) throw new Error('Missing waiting filter') + expect(new PgDialect().sqlToQuery(filter).sql).toContain('is null') +}) + +it('only lists unredeemed, unexpired invites as pending', async () => { + mocks.rows.push([], [{ total: 3 }]) + await listChatInvitesAdmin({ page: 0, status: 'pending' }) + const filter = mocks.filters[0] + if (!filter) throw new Error('Missing pending filter') + const sql = new PgDialect().sqlToQuery(filter).sql + expect(sql).toContain('"redeemed_at" is null') + expect(sql).toContain('"expires_at" >') + expect(mocks.limit).toHaveBeenCalledWith(25) +}) + +it('locks the user before granting access and preserves an existing grant', async () => { + mocks.rows.push([{ userId: 'person' }]) + await grantChatAccessAdmin('person') + expect(mocks.lock).toHaveBeenCalledOnce() + expect(mocks.lock.mock.invocationCallOrder[0]).toBeLessThan( + mocks.insert.mock.invocationCallOrder[0], + ) + expect(mocks.insert).toHaveBeenCalledWith({ + userId: 'person', + invitedBy: 'admin', + }) +}) + +it('does not grant access to a user outside the waitlist', async () => { + mocks.rows.push([]) + await expect(grantChatAccessAdmin('person')).rejects.toThrow( + 'not on the TanChat waitlist', + ) + expect(mocks.insert).not.toHaveBeenCalled() +}) + +it('creates admin invites through the existing hashed-token flow', async () => { + mocks.createInvite.mockResolvedValue({ token: 'new-invite' }) + expect(await createChatInviteAdmin()).toEqual({ token: 'new-invite' }) + expect(mocks.createInvite).toHaveBeenCalledWith({ userId: 'admin' }) +}) + +it('expires only unused, active invites without deleting quota history', async () => { + mocks.rows.push([{ tokenHash: 'hash' }]) + await expireChatInviteAdmin('hash') + expect(mocks.update).toHaveBeenCalledWith({ expiresAt: expect.any(Date) }) + const filter = mocks.filters[0] + if (!filter) throw new Error('Missing expiry filter') + const sql = new PgDialect().sqlToQuery(filter).sql + expect(sql).toContain('"redeemed_at" is null') + expect(sql).toContain('"expires_at" >') +}) + +it('reports when redemption won the race against expiry', async () => { + mocks.rows.push([]) + await expect(expireChatInviteAdmin('hash')).rejects.toThrow( + 'already been used or expired', + ) +}) diff --git a/src/chat/access-admin.functions.ts b/src/chat/access-admin.functions.ts new file mode 100644 index 000000000..3507495e4 --- /dev/null +++ b/src/chat/access-admin.functions.ts @@ -0,0 +1,37 @@ +import { createServerFn } from '@tanstack/react-start' +import { z } from 'zod' +import { + listChatWaitlistAdmin, + listChatInvitesAdmin, + grantChatAccessAdmin, + createChatInviteAdmin, + expireChatInviteAdmin, +} from './access-admin.server' + +const pagination = z.object({ page: z.number().int().min(0).max(100000) }) + +export const listChatWaitlist = createServerFn({ method: 'GET' }) + .validator( + pagination.extend({ status: z.enum(['waiting', 'granted', 'all']) }), + ) + .handler(({ data }) => listChatWaitlistAdmin(data)) + +export const listChatInvites = createServerFn({ method: 'GET' }) + .validator( + pagination.extend({ + status: z.enum(['pending', 'redeemed', 'expired', 'all']), + }), + ) + .handler(({ data }) => listChatInvitesAdmin(data)) + +export const grantChatAccess = createServerFn({ method: 'POST' }) + .validator(z.object({ userId: z.uuid() })) + .handler(({ data }) => grantChatAccessAdmin(data.userId)) + +export const createAdminChatInvite = createServerFn({ method: 'POST' }).handler( + () => createChatInviteAdmin(), +) + +export const expireChatInvite = createServerFn({ method: 'POST' }) + .validator(z.object({ tokenHash: z.string().regex(/^[a-f0-9]{64}$/) })) + .handler(({ data }) => expireChatInviteAdmin(data.tokenHash)) diff --git a/src/chat/access-admin.server.ts b/src/chat/access-admin.server.ts new file mode 100644 index 000000000..439e20a8a --- /dev/null +++ b/src/chat/access-admin.server.ts @@ -0,0 +1,136 @@ +import { + and, + asc, + desc, + eq, + gt, + isNotNull, + isNull, + lte, + sql, +} from 'drizzle-orm' +import { alias } from 'drizzle-orm/pg-core' +import { getRequest } from '@tanstack/react-start/server' +import { getAuthGuards } from '~/auth/index.server' +import { db } from '~/db/client' +import { chatAccess, chatInvites, chatWaitlist, users } from '~/db/schema' +import { createChatInvite } from './access.server' + +const pageSize = 25 + +export async function listChatWaitlistAdmin(input: { + page: number + status: 'waiting' | 'granted' | 'all' +}) { + await getAuthGuards().requireCapability(getRequest(), 'admin') + const filter = + input.status === 'waiting' + ? isNull(chatAccess.userId) + : input.status === 'granted' + ? isNotNull(chatAccess.userId) + : undefined + const [entries, [count]] = await Promise.all([ + db + .select({ + userId: users.id, + name: users.name, + email: users.email, + joinedAt: chatWaitlist.createdAt, + grantedAt: chatAccess.createdAt, + }) + .from(chatWaitlist) + .innerJoin(users, eq(users.id, chatWaitlist.userId)) + .leftJoin(chatAccess, eq(chatAccess.userId, chatWaitlist.userId)) + .where(filter) + .orderBy(asc(chatWaitlist.createdAt), asc(chatWaitlist.userId)) + .limit(pageSize) + .offset(input.page * pageSize), + db + .select({ total: sql`count(*)::int` }) + .from(chatWaitlist) + .leftJoin(chatAccess, eq(chatAccess.userId, chatWaitlist.userId)) + .where(filter), + ]) + return { entries, total: count.total, pageSize } +} + +export async function listChatInvitesAdmin(input: { + page: number + status: 'pending' | 'redeemed' | 'expired' | 'all' +}) { + await getAuthGuards().requireCapability(getRequest(), 'admin') + const now = new Date() + const filter = + input.status === 'pending' + ? and(isNull(chatInvites.redeemedAt), gt(chatInvites.expiresAt, now)) + : input.status === 'redeemed' + ? isNotNull(chatInvites.redeemedAt) + : input.status === 'expired' + ? and(isNull(chatInvites.redeemedAt), lte(chatInvites.expiresAt, now)) + : undefined + const recipient = alias(users, 'invite_recipient') + const [entries, [count]] = await Promise.all([ + db + .select({ + tokenHash: chatInvites.tokenHash, + createdBy: users.email, + redeemedBy: recipient.email, + createdAt: chatInvites.createdAt, + expiresAt: chatInvites.expiresAt, + redeemedAt: chatInvites.redeemedAt, + }) + .from(chatInvites) + .innerJoin(users, eq(users.id, chatInvites.createdBy)) + .leftJoin(recipient, eq(recipient.id, chatInvites.redeemedBy)) + .where(filter) + .orderBy(desc(chatInvites.createdAt), asc(chatInvites.tokenHash)) + .limit(pageSize) + .offset(input.page * pageSize), + db + .select({ total: sql`count(*)::int` }) + .from(chatInvites) + .where(filter), + ]) + return { entries, total: count.total, pageSize, serverTime: now } +} + +export async function grantChatAccessAdmin(userId: string) { + const admin = await getAuthGuards().requireCapability(getRequest(), 'admin') + await db.transaction(async (tx) => { + // Use the same user lock as invite redemption so approval cannot race it. + await tx.execute(sql`select id from users where id = ${userId} for update`) + const [entry] = await tx + .select({ userId: chatWaitlist.userId }) + .from(chatWaitlist) + .where(eq(chatWaitlist.userId, userId)) + if (!entry) throw new Error('This user is not on the TanChat waitlist.') + await tx + .insert(chatAccess) + .values({ userId, invitedBy: admin.userId }) + .onConflictDoNothing() + }) + return { userId } +} + +export async function createChatInviteAdmin() { + const admin = await getAuthGuards().requireCapability(getRequest(), 'admin') + return createChatInvite(admin) +} + +export async function expireChatInviteAdmin(tokenHash: string) { + await getAuthGuards().requireCapability(getRequest(), 'admin') + const now = new Date() + const [invite] = await db + .update(chatInvites) + .set({ expiresAt: now }) + .where( + and( + eq(chatInvites.tokenHash, tokenHash), + isNull(chatInvites.redeemedAt), + gt(chatInvites.expiresAt, now), + ), + ) + .returning({ tokenHash: chatInvites.tokenHash }) + if (!invite) throw new Error('This invite has already been used or expired.') + return invite +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index e40da438b..c86675182 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -56,6 +56,7 @@ import { Route as AccountNotesRouteImport } from './routes/account/notes' import { Route as AccountSubmissionsRouteImport } from './routes/account/submissions' import { Route as AdminIndexRouteImport } from './routes/admin/index' import { Route as AdminAuditRouteImport } from './routes/admin/audit' +import { Route as AdminChatAccessRouteImport } from './routes/admin/chat-access' import { Route as AdminDocsRouteImport } from './routes/admin/docs' import { Route as AdminGithubStatsRouteImport } from './routes/admin/github-stats' import { Route as AdminIntentRouteImport } from './routes/admin/intent' @@ -559,6 +560,11 @@ const AdminAuditRoute = AdminAuditRouteImport.update({ path: '/audit', getParentRoute: () => AdminRouteRoute, } as any) +const AdminChatAccessRoute = AdminChatAccessRouteImport.update({ + id: '/chat-access', + path: '/chat-access', + getParentRoute: () => AdminRouteRoute, +} as any) const AdminDocsRoute = AdminDocsRouteImport.update({ id: '/docs', path: '/docs', @@ -2044,6 +2050,7 @@ export interface FileRoutesByFullPath { '/account/notes': typeof AccountNotesRoute '/account/submissions': typeof AccountSubmissionsRoute '/admin/audit': typeof AdminAuditRoute + '/admin/chat-access': typeof AdminChatAccessRoute '/admin/docs': typeof AdminDocsRoute '/admin/github-stats': typeof AdminGithubStatsRoute '/admin/intent': typeof AdminIntentRoute @@ -2350,6 +2357,7 @@ export interface FileRoutesByTo { '/account/notes': typeof AccountNotesRoute '/account/submissions': typeof AccountSubmissionsRoute '/admin/audit': typeof AdminAuditRoute + '/admin/chat-access': typeof AdminChatAccessRoute '/admin/docs': typeof AdminDocsRoute '/admin/github-stats': typeof AdminGithubStatsRoute '/admin/intent': typeof AdminIntentRoute @@ -2663,6 +2671,7 @@ export interface FileRoutesById { '/account/notes': typeof AccountNotesRoute '/account/submissions': typeof AccountSubmissionsRoute '/admin/audit': typeof AdminAuditRoute + '/admin/chat-access': typeof AdminChatAccessRoute '/admin/docs': typeof AdminDocsRoute '/admin/github-stats': typeof AdminGithubStatsRoute '/admin/intent': typeof AdminIntentRoute @@ -2980,6 +2989,7 @@ export interface FileRouteTypes { | '/account/notes' | '/account/submissions' | '/admin/audit' + | '/admin/chat-access' | '/admin/docs' | '/admin/github-stats' | '/admin/intent' @@ -3286,6 +3296,7 @@ export interface FileRouteTypes { | '/account/notes' | '/account/submissions' | '/admin/audit' + | '/admin/chat-access' | '/admin/docs' | '/admin/github-stats' | '/admin/intent' @@ -3598,6 +3609,7 @@ export interface FileRouteTypes { | '/account/notes' | '/account/submissions' | '/admin/audit' + | '/admin/chat-access' | '/admin/docs' | '/admin/github-stats' | '/admin/intent' @@ -4351,6 +4363,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminAuditRouteImport parentRoute: typeof AdminRouteRoute } + '/admin/chat-access': { + id: '/admin/chat-access' + path: '/chat-access' + fullPath: '/admin/chat-access' + preLoaderRoute: typeof AdminChatAccessRouteImport + parentRoute: typeof AdminRouteRoute + } '/admin/docs': { id: '/admin/docs' path: '/docs' @@ -6237,6 +6256,7 @@ const AdminUsersRouteWithChildren = AdminUsersRoute._addFileChildren( interface AdminRouteRouteChildren { AdminAuditRoute: typeof AdminAuditRoute + AdminChatAccessRoute: typeof AdminChatAccessRoute AdminDocsRoute: typeof AdminDocsRoute AdminGithubStatsRoute: typeof AdminGithubStatsRoute AdminIntentRoute: typeof AdminIntentRoute @@ -6255,6 +6275,7 @@ interface AdminRouteRouteChildren { const AdminRouteRouteChildren: AdminRouteRouteChildren = { AdminAuditRoute: AdminAuditRoute, + AdminChatAccessRoute: AdminChatAccessRoute, AdminDocsRoute: AdminDocsRoute, AdminGithubStatsRoute: AdminGithubStatsRoute, AdminIntentRoute: AdminIntentRoute, diff --git a/src/routes/admin/audit.tsx b/src/routes/admin/audit.tsx index 76b0b0c87..5c78e6a2b 100644 --- a/src/routes/admin/audit.tsx +++ b/src/routes/admin/audit.tsx @@ -355,9 +355,14 @@ function AuditPage() { [], ) + const tableData = useMemo( + () => auditQuery.data?.page ?? [], + [auditQuery.data?.page], + ) + // eslint-disable-next-line react-hooks/incompatible-library const table = useReactTable({ - data: (auditQuery?.data?.page || []) as AuditLogEntry[], + data: tableData, columns, getCoreRowModel: getCoreRowModel(), manualPagination: true, diff --git a/src/routes/admin/chat-access.tsx b/src/routes/admin/chat-access.tsx new file mode 100644 index 000000000..b46fc56af --- /dev/null +++ b/src/routes/admin/chat-access.tsx @@ -0,0 +1,398 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import { useState } from 'react' +import { ChatsCircleIcon } from '@phosphor-icons/react' +import { + createAdminChatInvite, + expireChatInvite, + grantChatAccess, + listChatInvites, + listChatWaitlist, +} from '~/chat/access-admin.functions' +import { useAdminGuard } from '~/hooks/useAdminGuard' +import { + AdminAccessDenied, + AdminLoading, + AdminPageHeader, +} from '~/components/admin' +import { + Table, + TableHeader, + TableHeaderRow, + TableHeaderCell, + TableBody, + TableRow, + TableCell, +} from '~/components/TableComponents' +import { Button } from '~/ui' + +export const Route = createFileRoute('/admin/chat-access')({ + component: ChatAccessAdminPage, +}) + +export function ChatAccessAdminPage() { + const guard = useAdminGuard() + const queryClient = useQueryClient() + const [tab, setTab] = useState<'waitlist' | 'invites'>('waitlist') + const [page, setPage] = useState(0) + const [waitlistStatus, setWaitlistStatus] = useState< + 'waiting' | 'granted' | 'all' + >('waiting') + const [inviteStatus, setInviteStatus] = useState< + 'pending' | 'redeemed' | 'expired' | 'all' + >('pending') + const [inviteLink, setInviteLink] = useState('') + const [copied, setCopied] = useState(false) + const [copyError, setCopyError] = useState('') + const waitlistQuery = useQuery({ + queryKey: ['admin', 'chat-access', 'waitlist', page, waitlistStatus], + queryFn: () => listChatWaitlist({ data: { page, status: waitlistStatus } }), + enabled: guard.status === 'authorized' && tab === 'waitlist', + placeholderData: keepPreviousData, + }) + const invitesQuery = useQuery({ + queryKey: ['admin', 'chat-access', 'invites', page, inviteStatus], + queryFn: () => listChatInvites({ data: { page, status: inviteStatus } }), + enabled: guard.status === 'authorized' && tab === 'invites', + placeholderData: keepPreviousData, + }) + const refresh = () => + queryClient.invalidateQueries({ queryKey: ['admin', 'chat-access'] }) + const grantMutation = useMutation({ + mutationFn: (userId: string) => grantChatAccess({ data: { userId } }), + onSuccess: refresh, + }) + const expireMutation = useMutation({ + mutationFn: (tokenHash: string) => + expireChatInvite({ data: { tokenHash } }), + onSuccess: refresh, + }) + const createMutation = useMutation({ + mutationFn: () => createAdminChatInvite(), + onSuccess: async ({ token }) => { + setInviteLink( + `${window.location.origin}/chat-access?invite=${encodeURIComponent(token)}`, + ) + setCopied(false) + setCopyError('') + await refresh() + }, + }) + + if (guard.status === 'loading') return + if (guard.status === 'denied') return + + const query = tab === 'waitlist' ? waitlistQuery : invitesQuery + const error = + query.error ?? + grantMutation.error ?? + expireMutation.error ?? + createMutation.error + const invitesData = invitesQuery.data + const data = query.data + const total = data?.total ?? 0 + const pageSize = data?.pageSize ?? 25 + const empty = data?.entries.length === 0 + const inputClass = + 'rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-black/30 px-3 py-2 text-sm' + + return ( +
+
+ } + title="TanChat Access" + isLoading={query.isFetching} + actions={ + + } + /> +
+
+ + +
+ {tab === 'waitlist' ? ( + + ) : ( + <> + + + + )} +
+ {inviteLink && ( +
+ +
+ event.target.select()} + /> + +
+

+ Save this link before leaving. Existing invite links cannot be + recovered. +

+ {copyError && ( +

+ {copyError} +

+ )} +
+ )} + {error && ( +

+ {error.message} +

+ )} + {tab === 'waitlist' && ( +

+ Grants shown here exclude automatic access for admins and + maintainers. +

+ )} + {query.isPending ? ( + + ) : empty ? ( +

+ {tab === 'waitlist' + ? 'No waitlist entries match this status.' + : 'No invites match this status.'} +

+ ) : tab === 'waitlist' ? ( + + + + User + Joined + Access granted + Action + + + + {waitlistQuery.data?.entries.map((entry) => ( + + + {entry.name &&
{entry.name}
} + + {entry.email} + +
+ {entry.joinedAt.toLocaleString()} + + {entry.grantedAt?.toLocaleString() ?? 'Waiting'} + + + {!entry.grantedAt && ( + + )} + +
+ ))} +
+
+ ) : ( + + + + Created by + Created + Status + Recipient + Expires + Action + + + + {invitesData && + invitesData.entries.map((entry) => { + const pending = + !entry.redeemedAt && + entry.expiresAt.getTime() > invitesData.serverTime.getTime() + return ( + + {entry.createdBy} + {entry.createdAt.toLocaleString()} + + {entry.redeemedAt + ? `Redeemed ${entry.redeemedAt.toLocaleString()}` + : pending + ? 'Pending' + : 'Expired'} + + {entry.redeemedBy ?? 'None'} + {entry.expiresAt.toLocaleString()} + + {pending && ( + + )} + + + ) + })} + +
+ )} + +
+
+ ) +} diff --git a/src/routes/admin/github-stats.tsx b/src/routes/admin/github-stats.tsx index 8a36b7f72..2a447316c 100644 --- a/src/routes/admin/github-stats.tsx +++ b/src/routes/admin/github-stats.tsx @@ -309,9 +309,11 @@ function GitHubStatsAdmin() { [refreshingKey, refreshAllMutation, refreshMutation], ) + const tableData = useMemo(() => cacheEntries ?? [], [cacheEntries]) + // eslint-disable-next-line react-hooks/incompatible-library const table = useReactTable({ - data: cacheEntries ?? [], + data: tableData, columns, getCoreRowModel: getCoreRowModel(), }) diff --git a/src/routes/admin/logins.tsx b/src/routes/admin/logins.tsx index e00e3a38f..e373ace61 100644 --- a/src/routes/admin/logins.tsx +++ b/src/routes/admin/logins.tsx @@ -253,9 +253,14 @@ function LoginsPage() { [], ) + const tableData = useMemo( + () => loginsQuery.data?.page ?? [], + [loginsQuery.data?.page], + ) + // eslint-disable-next-line react-hooks/incompatible-library const table = useReactTable({ - data: (loginsQuery?.data?.page || []) as LoginHistoryEntry[], + data: tableData, columns, getCoreRowModel: getCoreRowModel(), manualPagination: true, diff --git a/src/routes/admin/roles.$roleId.tsx b/src/routes/admin/roles.$roleId.tsx index c8a663e83..707702e1e 100644 --- a/src/routes/admin/roles.$roleId.tsx +++ b/src/routes/admin/roles.$roleId.tsx @@ -219,9 +219,11 @@ function RoleDetailPage() { [selectedUserIds, usersWithRole, toggleAllSelection, toggleUserSelection], ) + const tableData = useMemo(() => usersWithRole ?? [], [usersWithRole]) + // eslint-disable-next-line react-hooks/incompatible-library const table = useReactTable({ - data: usersWithRole || [], + data: tableData, columns, getCoreRowModel: getCoreRowModel(), }) diff --git a/src/routes/admin/roles.index.tsx b/src/routes/admin/roles.index.tsx index ded0d002f..647cb891f 100644 --- a/src/routes/admin/roles.index.tsx +++ b/src/routes/admin/roles.index.tsx @@ -384,8 +384,10 @@ function RolesPage() { ) // Create table instance + const tableData = useMemo(() => roles ?? [], [roles]) + const table = useReactTable({ - data: roles || [], + data: tableData, columns, getCoreRowModel: getCoreRowModel(), }) diff --git a/src/routes/admin/route.tsx b/src/routes/admin/route.tsx index 5dffc6c60..5123e16bd 100644 --- a/src/routes/admin/route.tsx +++ b/src/routes/admin/route.tsx @@ -67,6 +67,12 @@ export function AdminLayout({ children }: { children: React.ReactNode }) { icon: , to: '/admin', }, + { + label: 'TanChat Access', + icon: , + to: '/admin/chat-access', + requiredCapability: 'admin', + }, { label: 'Users', icon: , diff --git a/src/routes/admin/users.tsx b/src/routes/admin/users.tsx index 65177d8cb..7bd83558c 100644 --- a/src/routes/admin/users.tsx +++ b/src/routes/admin/users.tsx @@ -754,8 +754,13 @@ function UsersPage() { const canGoNext = !usersQuery?.data?.isDone // Create table instance + const tableData = useMemo( + () => usersQuery.data?.page ?? [], + [usersQuery.data?.page], + ) + const table = useReactTable({ - data: (usersQuery?.data?.page || []) as User[], + data: tableData, columns, getCoreRowModel: getCoreRowModel(), manualPagination: true,