Skip to content
Open
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
79 changes: 18 additions & 61 deletions src/features/telegram/grants-page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { Link, useNavigate } from "@tanstack/react-router"
import { useNavigate } from "@tanstack/react-router"
import { CalendarPlus, ShieldCheck } from "lucide-react"
import { useDeferredValue, useMemo, useState } from "react"

import {
buttonMotion,
Chip,
DataTable,
type DataTableColumn,
EmptyState,
Expand All @@ -18,36 +17,30 @@ import { Count, PageBar, PageContent, Toolbar, useCan } from "@/components/shell
import { CreateGrantDialog } from "@/components/telegram/create-grant-dialog"
import { telegramUserName } from "@/components/telegram/telegram-user"
import { Button } from "@/components/ui/button"
import type { getTelegramGrantsWithGrantors } from "@/features/telegram/grants.functions"
import type { TgUser } from "@/lib/api/types"
import type { getTelegramGrants } from "@/features/telegram/grants.functions"
import { formatDate, formatDateTime } from "@/lib/format"

export type TelegramGrants = Awaited<ReturnType<typeof getTelegramGrantsWithGrantors>>
export type TelegramGrants = Awaited<ReturnType<typeof getTelegramGrants>>

type GrantRecord = TelegramGrants["ongoing"]["grants"][number]
type Status = "active" | "scheduled"
type Tab = "all" | "ongoing" | "scheduled"

type Row = GrantRecord & { grantor: TgUser | null; status: Status }
type Row = GrantRecord & { status: Status }

const TAB_STATUS = { ongoing: "active", scheduled: "scheduled" } satisfies Record<Exclude<Tab, "all">, Status>

function userName(user: GrantRecord["user"], id: number) {
return user ? telegramUserName(user) : `User ${id}`
}

function searchText({ grant, user, grantor, status }: Row) {
function searchText({ grant, user, status }: Row) {
return [
grant.userId,
user?.firstName,
user?.lastName,
user?.username,
grant.grantedBy,
grant.grantedBySub,
grant.source === "idp" ? "idp" : "legacy",
grantor?.firstName,
grantor?.lastName,
grantor?.username,
grant.reason,
formatDate(grant.validSince),
formatDate(grant.validUntil),
Expand Down Expand Up @@ -131,23 +124,15 @@ const columns: DataTableColumn<Row>[] = [
label: "Authorized by",
priority: 3,
minWidth: 130,
cell: ({ grant, grantor }) => (
cell: ({ grant }) => (
<span className="flex min-w-0 items-baseline gap-1">
<span aria-hidden className="hidden max-shell:inline">
By
</span>
<Grantor grant={grant} grantor={grantor} />
<Grantor grant={grant} />
</span>
),
},
{
id: "source",
label: "Source",
// Legacy grants were made before the IdP migration; both kinds count until the legacy table is dropped.
priority: 6,
minWidth: 100,
cell: ({ grant }) => <Chip>{grant.source === "idp" ? "IdP" : "Legacy"}</Chip>,
},
{
id: "starts",
label: "Starts",
Expand Down Expand Up @@ -175,44 +160,21 @@ const columns: DataTableColumn<Row>[] = [
},
]

/**
* Legacy grants name their grantor by Telegram ID, linked to that user; IdP grants by IdP subject (`grantedBySub`),
* which the backend does not resolve to a name.
*/
function Grantor({ grant, grantor }: Pick<Row, "grant" | "grantor">) {
if (grant.grantedBy === null) {
return grant.grantedBySub ? (
<Hint label={`IdP user ${grant.grantedBySub}`}>
<span className="block truncate font-mono text-(--pn-fg-muted)">{grant.grantedBySub}</span>
</Hint>
) : (
<Unset />
)
}
// Its own link to the grantor; the row click still opens the grantee.
return (
<Hint label={grantor?.username ? `@${grantor.username}` : undefined}>
<Link
to="/dashboard/telegram/users/$userId"
params={{ userId: String(grant.grantedBy) }}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
className={
grantor
? "block truncate text-(--pn-fg) underline-offset-2 transition-[color] duration-120 hover:text-(--pn-accent) hover:underline"
: "font-mono text-(--pn-fg) tabular-nums underline-offset-2 transition-[color] duration-120 hover:text-(--pn-accent) hover:underline"
}
>
{grantor ? telegramUserName(grantor) : grant.grantedBy}
</Link>
/** The grantor's IdP subject; the backend does not resolve it to a name. */
function Grantor({ grant }: Pick<Row, "grant">) {
return grant.grantedBySub ? (
<Hint label={`IdP user ${grant.grantedBySub}`}>
<span className="block truncate font-mono text-(--pn-fg-muted)">{grant.grantedBySub}</span>
</Hint>
) : (
<Unset />
)
}

const PAGE_SIZE = 20

/** Grants: ongoing and scheduled grants with a status filter; a row opens the grantee. */
export function TelegramGrantsPage({ grants: { ongoing, scheduled, grantors } }: { grants: TelegramGrants }) {
export function TelegramGrantsPage({ grants: { ongoing, scheduled } }: { grants: TelegramGrants }) {
const navigate = useNavigate()
const canWrite = useCan("tg:grants:manage")
const [query, setQuery] = useState("")
Expand All @@ -224,14 +186,9 @@ export function TelegramGrantsPage({ grants: { ongoing, scheduled, grantors } }:
const deferredQuery = useDeferredValue(query)

const current = useMemo(() => {
const grantorById = new Map(grantors.map((user) => [user.id, user]))
const toRow = (status: Status) => (record: GrantRecord) => ({
...record,
grantor: record.grant.grantedBy === null ? null : (grantorById.get(record.grant.grantedBy) ?? null),
status,
})
const toRow = (status: Status) => (record: GrantRecord) => ({ ...record, status })
return [...ongoing.grants.map(toRow("active")), ...scheduled.grants.map(toRow("scheduled"))]
}, [ongoing, scheduled, grantors])
}, [ongoing, scheduled])

const counts = {
all: current.length,
Expand Down Expand Up @@ -302,7 +259,7 @@ export function TelegramGrantsPage({ grants: { ongoing, scheduled, grantors } }:
label="Grants"
columns={columns}
rows={rows}
getRowId={(row) => row.grant.key}
getRowId={(row) => String(row.grant.id)}
sort={sort}
onSort={(next) => {
setSort(next)
Expand Down
32 changes: 3 additions & 29 deletions src/features/telegram/grants.functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,41 +3,15 @@ import { z } from "zod"

import { adminMiddleware, grantsWriteMiddleware } from "@/server/auth.middleware"

/** How long the grants page waits for grantor names before showing their ids instead. */
const GRANTOR_LOOKUP_MS = 2000

/**
* The grants page: both grant lists plus the Telegram users who authorized legacy grants, so "Authorized by" can show
* a name. Grants made through the IdP name their grantor by IdP subject (`grantedBySub`) instead, which the backend
* does not resolve to a name. Grantors are looked up by id once the grants are in (one batched request, none without
* legacy grants); they are decoration, so a failed or slow lookup leaves the page with the grantor ids.
*/
export const getTelegramGrantsWithGrantors = createServerFn()
/** The grants page: ongoing and scheduled grants. The grantor is an IdP subject (`grantedBySub`). */
export const getTelegramGrants = createServerFn()
.middleware([adminMiddleware])
.handler(async ({ context }) => {
const [ongoing, scheduled] = await Promise.all([
context.backend.tg.grants.getOngoing.query(),
context.backend.tg.grants.getScheduled.query(),
])
const grantorIds = new Set(
[...ongoing.grants, ...scheduled.grants].flatMap(({ grant }) =>
grant.grantedBy === null ? [] : [grant.grantedBy]
)
)
const signal = AbortSignal.timeout(GRANTOR_LOOKUP_MS)
const lookups = await Promise.all(
Array.from(grantorIds, (userId) =>
context.backend.tg.users.get
.query({ userId }, { signal })
.then(({ user }) => user)
.catch((error) => {
console.error(error)
return null
})
)
)
const grantors = lookups.filter((user) => user !== null && user !== undefined)
return { ongoing, scheduled, grantors }
return { ongoing, scheduled }
})

const grantInput = z
Expand Down
4 changes: 2 additions & 2 deletions src/features/telegram/user-detail/profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ export function TelegramUserDetailPage({ data }: { data: TelegramUserDetail }) {

type GrantWindow = Pick<TgGrant, "validSince" | "validUntil">

type GrantsSectionProps = { ongoing: GrantWindow | null; scheduled: (GrantWindow & { key: string })[] }
type GrantsSectionProps = { ongoing: GrantWindow | null; scheduled: (GrantWindow & { id: number })[] }

function GrantsSection({ ongoing, scheduled }: GrantsSectionProps) {
// `tg.grants.checkUser` returns the ongoing grant without a key or reason; a user has at most one.
Expand Down Expand Up @@ -214,7 +214,7 @@ function GrantsSection({ ongoing, scheduled }: GrantsSectionProps) {
) : null}
{scheduled.map((grant) => (
<li
key={grant.key}
key={grant.id}
className="flex flex-col gap-1.5 rounded-(--pn-r-4) border border-dashed border-(--pn-line-strong) p-3.5"
>
<div className="flex items-center justify-between gap-2">
Expand Down
6 changes: 3 additions & 3 deletions src/routes/dashboard/telegram/grants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ import { createFileRoute } from "@tanstack/react-router"
import { TableSkeleton } from "@/components/primitives"
import { PageBar, PageContent } from "@/components/shell"
import { TelegramGrantsPage } from "@/features/telegram/grants-page"
import { getTelegramGrantsWithGrantors } from "@/features/telegram/grants.functions"
import { getTelegramGrants } from "@/features/telegram/grants.functions"

export const Route = createFileRoute("/dashboard/telegram/grants")({
loader: () => getTelegramGrantsWithGrantors(),
loader: () => getTelegramGrants(),
pendingComponent: () => (
<>
<PageBar />
<PageContent>
<TableSkeleton columns={6} label="Loading grants…" />
<TableSkeleton columns={5} label="Loading grants…" />
</PageContent>
</>
),
Expand Down
Loading