Add admin dashboard filtering and sorting
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

Provide stable server-side list queries and focused chart controls so operators can inspect large datasets without misleading partial-page ordering.
This commit is contained in:
Rocky
2026-08-20 18:05:49 +08:00
parent 034a3e8745
commit 74c3fcd45f
39 changed files with 2990 additions and 431 deletions
+41 -19
View File
@@ -4,16 +4,21 @@ import type {
AdminOperatorProvisioning,
AdminSecuritySummary,
AdminLoginResponse,
AuditQuery,
AuditLogEntry,
CreditGrantRequest,
CreditGrantResponse,
LedgerQuery,
LedgerEntry,
OperatorsQuery,
Overview,
PageResult,
ProductAnalyticsOverview,
ReferralsQuery,
ReferralOverview,
SessionResponse,
UserDetail,
UsersQuery,
UserSummary,
} from "./types";
@@ -140,15 +145,21 @@ async function request<T>(
}
}
function query(params: Record<string, string | undefined>): string {
export function encodeQuery<T extends object>(params: T): string {
const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value) search.set(key, value);
if (value !== undefined && value !== "") search.set(key, String(value));
});
const result = search.toString();
return result ? `?${result}` : "";
}
function cursorQuery<T extends CursorQuery>(value?: string | T): CursorQuery | T {
return typeof value === "string" ? { cursor: value } : (value ?? {});
}
type CursorQuery = { cursor?: string };
export const adminApi = {
session: () => request<SessionResponse>("/auth/session"),
@@ -161,28 +172,35 @@ export const adminApi = {
logout: () => request<void>("/auth/logout", { method: "POST" }),
overview: (range: string) =>
request<Overview>(`/overview${query({ range })}`),
request<Overview>(`/overview${encodeQuery({ range })}`),
referrals: (range: string) =>
request<ReferralOverview>(`/referrals${query({ range })}`),
referrals: (value: string | ReferralsQuery) => {
const params = typeof value === "string" ? { range: value } : value;
return request<ReferralOverview>(`/referrals${encodeQuery(params)}`);
},
productAnalytics: (range: string) =>
request<ProductAnalyticsOverview>(`/analytics${query({ range })}`),
request<ProductAnalyticsOverview>(`/analytics${encodeQuery({ range })}`),
users: (search = "", cursor?: string) =>
request<PageResult<UserSummary>>(
`/users${query({ q: search.trim(), cursor })}`,
),
users: (value: string | UsersQuery = "", legacyCursor?: string) => {
const params =
typeof value === "string"
? { q: value.trim(), cursor: legacyCursor }
: { ...value, q: value.q?.trim() };
return request<PageResult<UserSummary>>(`/users${encodeQuery(params)}`);
},
user: (userId: string) =>
request<UserDetail>(`/users/${encodeURIComponent(userId)}`),
latestLedger: (cursor?: string) =>
request<PageResult<LedgerEntry>>(`/credits/ledger${query({ cursor })}`),
ledger: (userId: string, cursor?: string) =>
latestLedger: (value?: string | LedgerQuery) =>
request<PageResult<LedgerEntry>>(
`/users/${encodeURIComponent(userId)}/ledger${query({ cursor })}`,
`/credits/ledger${encodeQuery(cursorQuery(value))}`,
),
ledger: (userId: string, value?: string | LedgerQuery) =>
request<PageResult<LedgerEntry>>(
`/users/${encodeURIComponent(userId)}/ledger${encodeQuery(cursorQuery(value))}`,
),
grantCredits: (payload: CreditGrantRequest) =>
@@ -196,11 +214,15 @@ export const adminApi = {
headers: { "Idempotency-Key": payload.idempotencyKey },
}),
auditLogs: (cursor?: string) =>
request<PageResult<AuditLogEntry>>(`/audit${query({ cursor })}`),
auditLogs: (value?: string | AuditQuery) =>
request<PageResult<AuditLogEntry>>(
`/audit${encodeQuery(cursorQuery(value))}`,
),
operators: (cursor?: string) =>
request<PageResult<AdminOperator>>(`/operators${query({ cursor })}`),
operators: (value?: string | OperatorsQuery) =>
request<PageResult<AdminOperator>>(
`/operators${encodeQuery(cursorQuery(value))}`,
),
operatorSummary: () =>
request<AdminSecuritySummary>("/operators/summary"),
+50
View File
@@ -1,5 +1,55 @@
export type AdminRole = "SUPER_ADMIN" | "SUPPORT" | "ANALYST";
export type SortOrder = "asc" | "desc";
export type AdminAuditAction =
| "LOGIN_SUCCEEDED"
| "LOGIN_FAILED"
| "SESSION_REVOKED"
| "OPERATOR_CREATED"
| "OPERATOR_ENABLED"
| "OPERATOR_DISABLED"
| "OPERATOR_UNLOCKED"
| "OPERATOR_CREDENTIALS_RESET"
| "OPERATOR_SESSIONS_REVOKED"
| "MANUAL_CREDIT_GRANTED";
export interface CursorPageQuery {
cursor?: string;
limit?: number;
from?: string;
until?: string;
sort?: "createdAt";
order?: SortOrder;
}
export interface UsersQuery extends CursorPageQuery {
q?: string;
status?: UserSummary["status"];
}
export interface LedgerQuery extends CursorPageQuery {
type?: LedgerEntryType;
}
export interface AuditQuery extends CursorPageQuery {
action?: AdminAuditAction;
result?: AuditLogEntry["result"];
}
export interface OperatorsQuery extends Omit<CursorPageQuery, "sort"> {
role?: AdminRole;
enabled?: boolean;
locked?: boolean;
sort?: "createdAt" | "username" | "lastLoginAt";
}
export interface ReferralsQuery {
range: string;
sort?: "invited" | "qualified" | "creditsEarned";
order?: SortOrder;
limit?: number;
}
export type AuthState =
| { status: "anonymous" }
| { status: "authenticated"; operatorName: string; role: AdminRole };
@@ -0,0 +1,18 @@
import type { ReactNode } from "react";
export function ChartToolbar({
children,
label,
}: {
children: ReactNode;
label: string;
}) {
return (
<div
className="flex flex-wrap items-end gap-3 border-b border-border bg-surface-muted/25 px-5 py-4"
aria-label={label}
>
{children}
</div>
);
}
+46 -23
View File
@@ -1,10 +1,21 @@
import type { TrendPoint } from "../../api/types";
import { formatNumber } from "../../lib/format";
export function TrendChart({ points }: { points: TrendPoint[] }) {
export function TrendChart({
points,
showRegistrations = true,
showCredits = true,
}: {
points: TrendPoint[];
showRegistrations?: boolean;
showCredits?: boolean;
}) {
if (points.length === 0) {
return <div className="grid h-full place-items-center text-sm text-muted"></div>;
}
if (!showRegistrations && !showCredits) {
return <div className="grid h-full place-items-center text-sm text-muted"></div>;
}
const width = 760;
const height = 300;
@@ -53,26 +64,38 @@ export function TrendChart({ points }: { points: TrendPoint[] }) {
className="chart-grid-line"
/>
))}
<text x={paddingX} y={paddingY - 9} className="chart-label">
{formatNumber(registrationMax)}
</text>
<text x={width - paddingX} y={paddingY - 9} textAnchor="end" className="chart-label">
{formatNumber(creditMax)}
</text>
<polyline points={registrationLine} className="chart-line chart-line--primary" />
<polyline points={creditLine} className="chart-line chart-line--violet" />
{showRegistrations ? (
<text x={paddingX} y={paddingY - 9} className="chart-label">
{formatNumber(registrationMax)}
</text>
) : null}
{showCredits ? (
<text x={width - paddingX} y={paddingY - 9} textAnchor="end" className="chart-label">
{formatNumber(creditMax)}
</text>
) : null}
{showRegistrations ? (
<polyline points={registrationLine} className="chart-line chart-line--primary" />
) : null}
{showCredits ? (
<polyline points={creditLine} className="chart-line chart-line--violet" />
) : null}
{coordinates.map(({ point, x, registrationY, creditY }, index) => (
<g key={point.date}>
<circle cx={x} cy={registrationY} r="4" className="chart-dot chart-dot--primary">
<title>
{point.date} {formatNumber(point.registrations)}
</title>
</circle>
<circle cx={x} cy={creditY} r="3.5" className="chart-dot chart-dot--violet">
<title>
{point.date} {formatNumber(point.creditsUsed)}
</title>
</circle>
{showRegistrations ? (
<circle cx={x} cy={registrationY} r="4" className="chart-dot chart-dot--primary">
<title>
{point.date} {formatNumber(point.registrations)}
</title>
</circle>
) : null}
{showCredits ? (
<circle cx={x} cy={creditY} r="3.5" className="chart-dot chart-dot--violet">
<title>
{point.date} {formatNumber(point.creditsUsed)}
</title>
</circle>
) : null}
{index % labelEvery === 0 || index === points.length - 1 ? (
<text
x={x}
@@ -92,16 +115,16 @@ export function TrendChart({ points }: { points: TrendPoint[] }) {
<thead>
<tr>
<th scope="col">UTC </th>
<th scope="col"></th>
<th scope="col"></th>
{showRegistrations ? <th scope="col"></th> : null}
{showCredits ? <th scope="col"></th> : null}
</tr>
</thead>
<tbody>
{points.map((point) => (
<tr key={point.date}>
<td>{point.date}</td>
<td>{formatNumber(point.registrations)}</td>
<td>{formatNumber(point.creditsUsed)}</td>
{showRegistrations ? <td>{formatNumber(point.registrations)}</td> : null}
{showCredits ? <td>{formatNumber(point.creditsUsed)}</td> : null}
</tr>
))}
</tbody>
+52 -10
View File
@@ -5,7 +5,9 @@ import {
type LegacyColumnDef,
useLegacyTable,
} from "@tanstack/react-table/legacy";
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
import { type ReactNode } from "react";
import type { SortOrder } from "../api/types";
import { cn } from "../lib/utils";
import { EmptyState } from "./primitives";
@@ -18,6 +20,9 @@ export function DataTable<T extends RowData>({
emptyTitle = "暂无数据",
footer,
className,
sort,
sortableColumns,
onSortChange,
}: {
data: T[];
columns: DataColumn<T>[];
@@ -25,6 +30,9 @@ export function DataTable<T extends RowData>({
emptyTitle?: string;
footer?: ReactNode;
className?: string;
sort?: { key: string; order: SortOrder };
sortableColumns?: Record<string, string>;
onSortChange?: (key: string, order: SortOrder) => void;
}) {
const table = useLegacyTable({
data,
@@ -42,16 +50,50 @@ export function DataTable<T extends RowData>({
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="sticky top-0 z-[1] border-b border-border bg-surface/95 px-5 py-3.5 text-left text-[11px] font-bold uppercase tracking-[0.08em] text-muted backdrop-blur"
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
{headerGroup.headers.map((header) => {
const sortKey = sortableColumns?.[header.column.id];
const active = sortKey != null && sort?.key === sortKey;
const ariaSort = active
? sort.order === "asc"
? "ascending"
: "descending"
: sortKey
? "none"
: undefined;
return (
<th
key={header.id}
className="sticky top-0 z-[1] border-b border-border bg-surface/95 px-5 py-3.5 text-left text-[11px] font-bold uppercase tracking-[0.08em] text-muted backdrop-blur"
aria-sort={ariaSort}
>
{header.isPlaceholder ? null : sortKey && onSortChange ? (
<button
className="inline-flex min-h-8 items-center gap-1.5 rounded-lg px-1 text-left transition hover:text-foreground focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15"
type="button"
onClick={() =>
onSortChange(
sortKey,
active && sort.order === "desc" ? "asc" : "desc",
)
}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{active ? (
sort.order === "asc" ? (
<ArrowUp className="size-3.5" aria-hidden />
) : (
<ArrowDown className="size-3.5" aria-hidden />
)
) : (
<ArrowUpDown className="size-3.5 opacity-60" aria-hidden />
)}
</button>
) : (
flexRender(header.column.columnDef.header, header.getContext())
)}
</th>
);
})}
</tr>
))}
</thead>
@@ -0,0 +1,57 @@
export interface DateRangeValue {
from?: string;
until?: string;
}
function datePart(value?: string, exclusiveEnd = false): string {
if (!value) return "";
if (!exclusiveEnd) return value.slice(0, 10);
const date = new Date(value);
date.setUTCDate(date.getUTCDate() - 1);
return date.toISOString().slice(0, 10);
}
function utcDate(value: string, exclusiveEnd: boolean): string | undefined {
if (!value) return undefined;
const date = new Date(`${value}T00:00:00.000Z`);
if (exclusiveEnd) date.setUTCDate(date.getUTCDate() + 1);
return date.toISOString();
}
export function DateRangeControl({
value,
onChange,
}: {
value: DateRangeValue;
onChange: (value: DateRangeValue) => void;
}) {
return (
<fieldset className="flex min-w-0 flex-wrap items-end gap-2">
<legend className="sr-only"></legend>
<label className="grid gap-1 text-xs font-semibold text-muted">
<span></span>
<input
className="h-9 rounded-lg border border-border bg-input px-3 text-sm text-foreground outline-none focus:border-primary focus:ring-4 focus:ring-primary/10"
type="date"
value={datePart(value.from)}
max={datePart(value.until, true) || undefined}
onChange={(event) =>
onChange({ ...value, from: utcDate(event.target.value, false) })
}
/>
</label>
<label className="grid gap-1 text-xs font-semibold text-muted">
<span></span>
<input
className="h-9 rounded-lg border border-border bg-input px-3 text-sm text-foreground outline-none focus:border-primary focus:ring-4 focus:ring-primary/10"
type="date"
value={datePart(value.until, true)}
min={datePart(value.from) || undefined}
onChange={(event) =>
onChange({ ...value, until: utcDate(event.target.value, true) })
}
/>
</label>
</fieldset>
);
}
@@ -0,0 +1,55 @@
export interface FilterOption<T extends string> {
value: T;
label: string;
}
export function FilterControl<T extends string>({
label,
value,
options,
onChange,
}: {
label: string;
value: T;
options: readonly FilterOption<T>[];
onChange: (value: T) => void;
}) {
return (
<label className="grid gap-1 text-xs font-semibold text-muted">
<span>{label}</span>
<select
className="h-9 rounded-lg border border-border bg-input px-3 text-sm text-foreground outline-none focus:border-primary focus:ring-4 focus:ring-primary/10"
value={value}
onChange={(event) => onChange(event.target.value as T)}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
);
}
export function ToggleFilter({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<label className="flex min-h-9 cursor-pointer items-center gap-2 rounded-lg border border-border bg-input px-3 text-xs font-semibold text-foreground">
<input
className="size-4 accent-primary"
type="checkbox"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
/>
{label}
</label>
);
}
+57
View File
@@ -0,0 +1,57 @@
import type { SortOrder } from "../api/types";
export interface SortOption<T extends string> {
value: T;
label: string;
}
export function SortControl<T extends string>({
value,
order,
options,
onChange,
label = "排序",
}: {
value: T;
order: SortOrder;
options: readonly SortOption<T>[];
onChange: (value: T, order: SortOrder) => void;
label?: string;
}) {
return (
<fieldset className="flex min-w-0 flex-wrap items-end gap-2">
<legend className="sr-only">{label}</legend>
<label className="grid gap-1 text-xs font-semibold text-muted">
<span>{label}</span>
<select
className="h-9 rounded-lg border border-border bg-input px-3 text-sm text-foreground outline-none focus:border-primary focus:ring-4 focus:ring-primary/10"
value={value}
onChange={(event) => onChange(event.target.value as T, order)}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<div className="inline-flex rounded-xl border border-border bg-surface-muted p-1">
{(["desc", "asc"] as const).map((direction) => (
<button
key={direction}
className={`min-h-7 rounded-lg px-3 text-xs font-semibold transition ${
order === direction
? "bg-surface text-foreground shadow-sm"
: "text-muted hover:text-foreground"
}`}
type="button"
aria-pressed={order === direction}
onClick={() => onChange(value, direction)}
>
{direction === "desc" ? "降序" : "升序"}
</button>
))}
</div>
</fieldset>
);
}
@@ -0,0 +1,31 @@
import type { ReactNode } from "react";
import { Button } from "./primitives";
export function TableToolbar({
children,
active,
onClear,
}: {
children: ReactNode;
active: boolean;
onClear: () => void;
}) {
return (
<div
className="flex flex-wrap items-end gap-3 border-b border-border bg-surface-muted/25 p-4 sm:p-5"
aria-label="表格筛选与排序"
>
{children}
<Button
className="sm:ml-auto"
size="sm"
variant="ghost"
type="button"
disabled={!active}
onClick={onClear}
>
</Button>
</div>
);
}
@@ -9,27 +9,47 @@ import {
Target,
Users,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { adminApi } from "../../api/client";
import type { AnalyticsRate, ProductAnalyticsOverview } from "../../api/types";
import type {
AnalyticsRate,
ProductAnalyticsOverview,
SortOrder,
} from "../../api/types";
import { ChartToolbar } from "../../components/chart-toolbar";
import { CohortHeatmap } from "../../components/charts/cohort-heatmap";
import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart";
import { FunnelChart } from "../../components/charts/funnel-chart";
import { FilterControl, ToggleFilter } from "../../components/filter-control";
import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
import { RangeControl } from "../../components/range-control";
import { SortControl } from "../../components/sort-control";
import { formatNumber } from "../../lib/format";
import { stableSort } from "../../lib/sort";
export function AnalyticsPage() {
const [range, setRange] = useState("30d");
const [data, setData] = useState<ProductAnalyticsOverview>();
const [error, setError] = useState<unknown>();
const [executionMode, setExecutionMode] = useState("");
const [aiSort, setAiSort] = useState<"successes" | "users">("successes");
const [aiOrder, setAiOrder] = useState<SortOrder>("desc");
const [channelSort, setChannelSort] = useState<"installs" | "activated" | "rate">("installs");
const [channelOrder, setChannelOrder] = useState<SortOrder>("desc");
const [hideUnknown, setHideUnknown] = useState(false);
const [cohortOrder, setCohortOrder] = useState<SortOrder>("desc");
const [minimumCohortSize, setMinimumCohortSize] = useState(0);
const [matureOnly, setMatureOnly] = useState(false);
const requestVersion = useRef(0);
const load = useCallback(async () => {
const version = ++requestVersion.current;
setError(undefined);
try {
setData(await adminApi.productAnalytics(range));
const response = await adminApi.productAnalytics(range);
if (requestVersion.current === version) setData(response);
} catch (requestError) {
setError(requestError);
if (requestVersion.current === version) setError(requestError);
}
}, [range]);
@@ -37,6 +57,47 @@ export function AnalyticsPage() {
void load();
}, [load]);
const visibleAiFeatures = useMemo(
() =>
stableSort(
(data?.aiFeatures ?? []).filter(
(item) => !executionMode || item.executionMode === executionMode,
),
(item) => (aiSort === "successes" ? item.successes : item.users),
aiOrder,
),
[aiOrder, aiSort, data?.aiFeatures, executionMode],
);
const visibleChannels = useMemo(
() =>
stableSort(
(data?.growth.channels ?? []).filter(
(channel) => !hideUnknown || channel.channel !== "UNKNOWN",
),
(channel) =>
channelSort === "installs"
? channel.installations
: channelSort === "activated"
? channel.activated
: channel.activationRate.percent ?? -1,
channelOrder,
),
[channelOrder, channelSort, data?.growth.channels, hideUnknown],
);
const visibleCohorts = useMemo(
() =>
stableSort(
(data?.retention ?? []).filter(
(cohort) =>
cohort.size >= minimumCohortSize &&
(!matureOnly || cohort.d30?.percent != null),
),
(cohort) => cohort.cohortDate,
cohortOrder,
),
[cohortOrder, data?.retention, matureOnly, minimumCohortSize],
);
if (error) return <ErrorState error={error} retry={() => void load()} />;
if (!data) return <LoadingState label="加载产品数据" />;
@@ -100,7 +161,28 @@ export function AnalyticsPage() {
description="按首次 AI 成功日分组,未成熟窗口显示为 —"
icon={ChartNoAxesCombined}
/>
<CohortHeatmap cohorts={data.retention} />
<ChartToolbar label="留存 cohort 筛选与排序">
<SortControl
label="激活日期"
value="date"
order={cohortOrder}
options={[{ value: "date", label: "激活日期" }]}
onChange={(_, order) => setCohortOrder(order)}
/>
<FilterControl
label="最小样本量"
value={String(minimumCohortSize)}
options={[
{ value: "0", label: "不限" },
{ value: "10", label: "至少 10 人" },
{ value: "50", label: "至少 50 人" },
{ value: "100", label: "至少 100 人" },
]}
onChange={(value) => setMinimumCohortSize(Number(value))}
/>
<ToggleFilter label="仅显示 D30 已成熟" checked={matureOnly} onChange={setMatureOnly} />
</ChartToolbar>
<CohortHeatmap cohorts={visibleCohorts} />
</Card>
</section>
@@ -111,13 +193,36 @@ export function AnalyticsPage() {
description="客户端功能与执行模式,仅包含白名单元数据"
icon={BrainCircuit}
/>
{data.aiFeatures.length === 0 ? (
<p className="p-8 text-center text-sm text-muted"></p>
<ChartToolbar label="AI 使用结构筛选与排序">
<FilterControl
label="执行模式"
value={executionMode}
options={[
{ value: "", label: "全部模式" },
{ value: "MANAGED", label: "托管" },
{ value: "LOCAL", label: "本地" },
{ value: "BYOK", label: "BYOK" },
]}
onChange={setExecutionMode}
/>
<SortControl
value={aiSort}
order={aiOrder}
options={[
{ value: "successes", label: "成功次数" },
{ value: "users", label: "成功用户" },
]}
onChange={(value, order) => {
setAiSort(value);
setAiOrder(order);
}}
/>
</ChartToolbar>
{visibleAiFeatures.length === 0 ? (
<p className="p-8 text-center text-sm text-muted"> AI 使</p>
) : (
<ComparisonBarChart
items={[...data.aiFeatures]
.sort((left, right) => right.successes - left.successes)
.map((item) => ({
items={visibleAiFeatures.map((item) => ({
id: `${item.feature}-${item.executionMode}`,
label: featureLabel(item.feature),
value: item.successes,
@@ -227,9 +332,25 @@ export function AnalyticsPage() {
<Card className="overflow-hidden">
<SectionHeader title="渠道质量" description="新增不是终点,重点比较 24 小时激活" icon={Users} />
<ChartToolbar label="渠道质量筛选与排序">
<SortControl
value={channelSort}
order={channelOrder}
options={[
{ value: "installs", label: "新增安装" },
{ value: "activated", label: "激活人数" },
{ value: "rate", label: "激活率" },
]}
onChange={(value, order) => {
setChannelSort(value);
setChannelOrder(order);
}}
/>
<ToggleFilter label="隐藏未知来源" checked={hideUnknown} onChange={setHideUnknown} />
</ChartToolbar>
<div className="grid gap-0 xl:grid-cols-[1fr_260px]">
<ComparisonBarChart
items={data.growth.channels.map((channel) => ({
items={visibleChannels.map((channel) => ({
label: channelLabel(channel.channel),
value: channel.installations,
secondaryValue: channel.activated,
@@ -237,7 +358,7 @@ export function AnalyticsPage() {
}))}
primaryLabel="新增安装"
secondaryLabel="24 小时激活"
emptyText="暂无渠道归因数据"
emptyText="当前筛选条件下暂无渠道归因数据"
/>
<MetricRows
rows={[
+110 -42
View File
@@ -1,9 +1,16 @@
import { FileCheck2, ShieldCheck } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { adminApi, ApiError } from "../../api/client";
import type { AuditLogEntry } from "../../api/types";
import { useSearchParams } from "react-router-dom";
import { adminApi } from "../../api/client";
import type {
AdminAuditAction,
AuditLogEntry,
AuditQuery,
SortOrder,
} from "../../api/types";
import { DataTable, type DataColumn } from "../../components/data-table";
import { DateRangeControl } from "../../components/date-range-control";
import { FilterControl } from "../../components/filter-control";
import {
Badge,
Button,
@@ -12,48 +19,56 @@ import {
LoadingState,
PageHeader,
} from "../../components/primitives";
import { TableToolbar } from "../../components/table-toolbar";
import { useCursorPage } from "../../hooks/use-cursor-page";
import { formatDateTime, statusLabel } from "../../lib/format";
export function AuditPage() {
const [items, setItems] = useState<AuditLogEntry[]>([]);
const [nextCursor, setNextCursor] = useState<string>();
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<unknown>();
const load = useCallback(async () => {
setLoading(true);
setError(undefined);
try {
const page = await adminApi.auditLogs();
setItems(page.items);
setNextCursor(page.nextCursor);
} catch (requestError) {
setError(requestError);
} finally {
setLoading(false);
}
}, []);
const [searchParams, setSearchParams] = useSearchParams();
const [from, setFrom] = useState(searchParams.get("from") ?? "");
const [until, setUntil] = useState(searchParams.get("until") ?? "");
const [action, setAction] = useState<"" | AdminAuditAction>(
(searchParams.get("action") as AdminAuditAction | null) ?? "",
);
const [result, setResult] = useState<"" | AuditLogEntry["result"]>(
(searchParams.get("result") as AuditLogEntry["result"] | null) ?? "",
);
const [order, setOrder] = useState<SortOrder>(
searchParams.get("order") === "asc" ? "asc" : "desc",
);
const auditQuery = useMemo<AuditQuery>(
() => ({
from: from || undefined,
until: until || undefined,
action: action || undefined,
result: result || undefined,
sort: "createdAt",
order,
limit: 50,
}),
[action, from, order, result, until],
);
const fetchAudit = useCallback((value: AuditQuery) => adminApi.auditLogs(value), []);
const {
items,
nextCursor,
loading,
loadingMore,
error,
loadMore,
reload,
} = useCursorPage(auditQuery, fetchAudit);
useEffect(() => {
void load();
}, [load]);
async function loadMore() {
if (!nextCursor) return;
setLoadingMore(true);
try {
const page = await adminApi.auditLogs(nextCursor);
setItems((current) => [...current, ...page.items]);
setNextCursor(page.nextCursor);
} catch (requestError) {
toast.error(
requestError instanceof ApiError ? requestError.message : "加载审计记录失败",
);
} finally {
setLoadingMore(false);
}
}
const params = new URLSearchParams();
if (from) params.set("from", from);
if (until) params.set("until", until);
if (action) params.set("action", action);
if (result) params.set("result", result);
params.set("sort", "createdAt");
params.set("order", order);
setSearchParams(params, { replace: true });
}, [action, from, order, result, setSearchParams, until]);
const columns = useMemo<DataColumn<AuditLogEntry>[]>(
() => [
@@ -140,9 +155,55 @@ export function AuditPage() {
</Card>
<Card className="overflow-hidden">
<TableToolbar
active={Boolean(from || until || action || result || order !== "desc")}
onClear={() => {
setFrom("");
setUntil("");
setAction("");
setResult("");
setOrder("desc");
}}
>
<DateRangeControl
value={{ from: from || undefined, until: until || undefined }}
onChange={(value) => {
setFrom(value.from ?? "");
setUntil(value.until ?? "");
}}
/>
<FilterControl
label="操作类型"
value={action}
options={[
{ value: "", label: "全部操作" },
{ value: "LOGIN_SUCCEEDED", label: "登录成功" },
{ value: "LOGIN_FAILED", label: "登录失败" },
{ value: "SESSION_REVOKED", label: "会话撤销" },
{ value: "OPERATOR_CREATED", label: "创建管理员" },
{ value: "OPERATOR_ENABLED", label: "启用管理员" },
{ value: "OPERATOR_DISABLED", label: "停用管理员" },
{ value: "OPERATOR_UNLOCKED", label: "解锁管理员" },
{ value: "OPERATOR_CREDENTIALS_RESET", label: "重置管理员凭据" },
{ value: "OPERATOR_SESSIONS_REVOKED", label: "撤销管理员会话" },
{ value: "MANUAL_CREDIT_GRANTED", label: "人工赠送积分" },
]}
onChange={setAction}
/>
<FilterControl
label="执行结果"
value={result}
options={[
{ value: "", label: "全部结果" },
{ value: "success", label: "成功" },
{ value: "rejected", label: "已拒绝" },
]}
onChange={setResult}
/>
</TableToolbar>
{error ? (
<div className="p-6">
<ErrorState error={error} retry={() => void load()} />
<ErrorState error={error} retry={reload} />
</div>
) : loading ? (
<LoadingState label="加载审计日志" />
@@ -151,7 +212,14 @@ export function AuditPage() {
data={items}
columns={columns}
caption="管理员审计事件"
emptyTitle="暂无审计记录"
emptyTitle={
from || until || action || result
? "没有符合当前筛选条件的审计记录"
: "暂无审计记录"
}
sort={{ key: "createdAt", order }}
sortableColumns={{ createdAt: "createdAt" }}
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
footer={
nextCursor ? (
<div className="flex justify-center border-t border-border p-5">
+97 -48
View File
@@ -1,8 +1,11 @@
import { ArrowDownUp, Coins, Search } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
import { adminApi, ApiError } from "../../api/client";
import type { LedgerEntry } from "../../api/types";
import { useSearchParams } from "react-router-dom";
import { adminApi } from "../../api/client";
import type { LedgerEntry, LedgerEntryType, LedgerQuery, SortOrder } from "../../api/types";
import { DataTable, type DataColumn } from "../../components/data-table";
import { DateRangeControl } from "../../components/date-range-control";
import { FilterControl } from "../../components/filter-control";
import {
Badge,
Button,
@@ -12,6 +15,8 @@ import {
LoadingState,
PageHeader,
} from "../../components/primitives";
import { TableToolbar } from "../../components/table-toolbar";
import { useCursorPage } from "../../hooks/use-cursor-page";
import {
formatDateTime,
formatNumber,
@@ -19,58 +24,63 @@ import {
statusLabel,
usageTypeLabel,
} from "../../lib/format";
import { toast } from "sonner";
export function CreditsPage() {
const [query, setQuery] = useState("");
const [activeUserId, setActiveUserId] = useState<string>();
const [items, setItems] = useState<LedgerEntry[]>([]);
const [nextCursor, setNextCursor] = useState<string>();
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<unknown>();
const load = useCallback(async (userId?: string) => {
setLoading(true);
setError(undefined);
try {
const page = userId
? await adminApi.ledger(userId)
: await adminApi.latestLedger();
setItems(page.items);
setNextCursor(page.nextCursor);
} catch (requestError) {
setError(requestError);
} finally {
setLoading(false);
}
}, []);
const [searchParams, setSearchParams] = useSearchParams();
const initialUserId = searchParams.get("userId") ?? "";
const [query, setQuery] = useState(initialUserId);
const [activeUserId, setActiveUserId] = useState(initialUserId);
const [from, setFrom] = useState(searchParams.get("from") ?? "");
const [until, setUntil] = useState(searchParams.get("until") ?? "");
const [type, setType] = useState<"" | LedgerEntryType>(
(searchParams.get("type") as LedgerEntryType | null) ?? "",
);
const [order, setOrder] = useState<SortOrder>(
searchParams.get("order") === "asc" ? "asc" : "desc",
);
const ledgerQuery = useMemo<LedgerQuery>(
() => ({
from: from || undefined,
until: until || undefined,
type: type || undefined,
sort: "createdAt",
order,
limit: 50,
}),
[from, order, type, until],
);
const fetchLedger = useCallback(
(value: LedgerQuery) =>
activeUserId
? adminApi.ledger(activeUserId, value)
: adminApi.latestLedger(value),
[activeUserId],
);
const {
items,
nextCursor,
loading,
loadingMore,
error,
loadMore,
reload,
} = useCursorPage(ledgerQuery, fetchLedger);
useEffect(() => {
void load();
}, [load]);
async function loadMore() {
if (!nextCursor) return;
setLoadingMore(true);
try {
const page = activeUserId
? await adminApi.ledger(activeUserId, nextCursor)
: await adminApi.latestLedger(nextCursor);
setItems((current) => [...current, ...page.items]);
setNextCursor(page.nextCursor);
} catch (requestError) {
toast.error(requestError instanceof ApiError ? requestError.message : "加载流水失败");
} finally {
setLoadingMore(false);
}
}
const params = new URLSearchParams();
if (activeUserId) params.set("userId", activeUserId);
if (from) params.set("from", from);
if (until) params.set("until", until);
if (type) params.set("type", type);
params.set("sort", "createdAt");
params.set("order", order);
setSearchParams(params, { replace: true });
}, [activeUserId, from, order, setSearchParams, type, until]);
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const userId = query.trim() || undefined;
setActiveUserId(userId);
void load(userId);
setActiveUserId(userId ?? "");
}
const columns = useMemo<DataColumn<LedgerEntry>[]>(
@@ -171,6 +181,38 @@ export function CreditsPage() {
</Button>
</form>
</div>
<TableToolbar
active={Boolean(activeUserId || from || until || type || order !== "desc")}
onClear={() => {
setQuery("");
setActiveUserId("");
setFrom("");
setUntil("");
setType("");
setOrder("desc");
}}
>
<DateRangeControl
value={{ from: from || undefined, until: until || undefined }}
onChange={(value) => {
setFrom(value.from ?? "");
setUntil(value.until ?? "");
}}
/>
<FilterControl
label="流水类型"
value={type}
options={[
{ value: "", label: "全部类型" },
{ value: "grant", label: "赠送" },
{ value: "reserve", label: "预留" },
{ value: "settle", label: "结算" },
{ value: "refund", label: "退款" },
{ value: "adjustment", label: "调整" },
]}
onChange={setType}
/>
</TableToolbar>
<div className="flex items-center gap-3 border-b border-border bg-surface-muted/35 px-5 py-3 text-xs text-muted sm:px-6">
<Coins className="size-4 text-primary" aria-hidden />
@@ -186,7 +228,7 @@ export function CreditsPage() {
{error ? (
<div className="p-6">
<ErrorState error={error} retry={() => void load(activeUserId)} />
<ErrorState error={error} retry={reload} />
</div>
) : loading ? (
<LoadingState label="加载积分流水" />
@@ -195,7 +237,14 @@ export function CreditsPage() {
data={items}
columns={columns}
caption={activeUserId ? `用户 ${activeUserId} 的积分流水` : "最新积分流水"}
emptyTitle={activeUserId ? "该用户暂无积分流水" : "暂无积分流水"}
emptyTitle={
activeUserId || from || until || type
? "没有符合当前筛选条件的流水"
: "暂无积分流水"
}
sort={{ key: "createdAt", order }}
sortableColumns={{ createdAt: "createdAt" }}
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
footer={
nextCursor ? (
<div className="flex justify-center border-t border-border p-5">
@@ -4,28 +4,39 @@ import {
CreditCard,
Users,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { adminApi } from "../../api/client";
import type { Overview } from "../../api/types";
import type { Overview, SortOrder } from "../../api/types";
import { ChartToolbar } from "../../components/chart-toolbar";
import { ChartLegend } from "../../components/charts/chart-legend";
import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart";
import { RadialMetric } from "../../components/charts/radial-metric";
import { TrendChart } from "../../components/charts/trend-chart";
import { ToggleFilter } from "../../components/filter-control";
import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
import { RangeControl } from "../../components/range-control";
import { SortControl } from "../../components/sort-control";
import { formatNumber, usageTypeLabel } from "../../lib/format";
import { stableSort } from "../../lib/sort";
export function OverviewPage() {
const [range, setRange] = useState("30d");
const [data, setData] = useState<Overview>();
const [error, setError] = useState<unknown>();
const [showRegistrations, setShowRegistrations] = useState(true);
const [showCredits, setShowCredits] = useState(true);
const [usageSort, setUsageSort] = useState<"credits" | "requests">("credits");
const [usageOrder, setUsageOrder] = useState<SortOrder>("desc");
const requestVersion = useRef(0);
const load = useCallback(async () => {
const version = ++requestVersion.current;
setError(undefined);
try {
setData(await adminApi.overview(range));
const response = await adminApi.overview(range);
if (requestVersion.current === version) setData(response);
} catch (requestError) {
setError(requestError);
if (requestVersion.current === version) setError(requestError);
}
}, [range]);
@@ -33,6 +44,16 @@ export function OverviewPage() {
void load();
}, [load]);
const sortedUsage = useMemo(
() =>
stableSort(
data?.usage ?? [],
(item) => (usageSort === "credits" ? item.chargedCredits : item.requests),
usageOrder,
),
[data?.usage, usageOrder, usageSort],
);
if (error) return <ErrorState error={error} retry={() => void load()} />;
if (!data) return <LoadingState label="加载运营总览" />;
@@ -88,13 +109,29 @@ export function OverviewPage() {
</div>
<ChartLegend
items={[
{ label: "新增用户", tone: "primary" },
{ label: "积分消耗", tone: "violet" },
...(showRegistrations ? [{ label: "新增用户", tone: "primary" as const }] : []),
...(showCredits ? [{ label: "积分消耗", tone: "violet" as const }] : []),
]}
/>
</div>
<div className="mb-4 flex flex-wrap gap-2" aria-label="趋势系列显隐">
<ToggleFilter
label="新增用户"
checked={showRegistrations}
onChange={setShowRegistrations}
/>
<ToggleFilter
label="积分消耗"
checked={showCredits}
onChange={setShowCredits}
/>
</div>
<div className="h-[320px] w-full">
<TrendChart points={data.trend} />
<TrendChart
points={data.trend}
showRegistrations={showRegistrations}
showCredits={showCredits}
/>
</div>
</Card>
@@ -129,13 +166,25 @@ export function OverviewPage() {
<p className="mt-1 text-xs text-muted"></p>
</div>
</div>
<ChartToolbar label="使用构成排序">
<SortControl
value={usageSort}
order={usageOrder}
options={[
{ value: "credits", label: "消耗积分" },
{ value: "requests", label: "请求次数" },
]}
onChange={(value, order) => {
setUsageSort(value);
setUsageOrder(order);
}}
/>
</ChartToolbar>
{data.usage.length === 0 ? (
<div className="p-8 text-center text-sm text-muted">使</div>
) : (
<ComparisonBarChart
items={[...data.usage]
.sort((left, right) => right.chargedCredits - left.chargedCredits)
.map((item) => ({
items={sortedUsage.map((item) => ({
label: usageTypeLabel(item.kind),
value: item.chargedCredits,
hint: `${formatNumber(item.requests)} 次请求`,
@@ -1,11 +1,19 @@
import { Award, CircleOff, Clock3, GitBranch, TrendingUp, Users } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { adminApi } from "../../api/client";
import type { ReferralOverview, ReferralRankingItem } from "../../api/types";
import type {
ReferralOverview,
ReferralRankingItem,
ReferralsQuery,
SortOrder,
} from "../../api/types";
import { ChartToolbar } from "../../components/chart-toolbar";
import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart";
import { FunnelChart } from "../../components/charts/funnel-chart";
import { RadialMetric } from "../../components/charts/radial-metric";
import { DataTable, type DataColumn } from "../../components/data-table";
import { FilterControl } from "../../components/filter-control";
import {
Card,
ErrorState,
@@ -14,26 +22,51 @@ import {
StatCard,
} from "../../components/primitives";
import { RangeControl } from "../../components/range-control";
import { SortControl } from "../../components/sort-control";
import { formatNumber } from "../../lib/format";
export function ReferralsPage() {
const [range, setRange] = useState("30d");
const [searchParams, setSearchParams] = useSearchParams();
const [range, setRange] = useState(searchParams.get("range") ?? "30d");
const [sort, setSort] = useState<NonNullable<ReferralsQuery["sort"]>>(
(searchParams.get("sort") as ReferralsQuery["sort"] | null) ?? "qualified",
);
const [order, setOrder] = useState<SortOrder>(
searchParams.get("order") === "asc" ? "asc" : "desc",
);
const [limit, setLimit] = useState(() => {
const value = Number(searchParams.get("limit"));
return [20, 50, 100].includes(value) ? value : 20;
});
const [data, setData] = useState<ReferralOverview>();
const [error, setError] = useState<unknown>();
const requestVersion = useRef(0);
const query = useMemo<ReferralsQuery>(
() => ({ range, sort, order, limit }),
[limit, order, range, sort],
);
const load = useCallback(async () => {
const version = ++requestVersion.current;
setError(undefined);
setData(undefined);
try {
setData(await adminApi.referrals(range));
const response = await adminApi.referrals(query);
if (requestVersion.current === version) setData(response);
} catch (requestError) {
setError(requestError);
if (requestVersion.current === version) setError(requestError);
}
}, [range]);
}, [query]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
const params = new URLSearchParams({ range, sort, order, limit: String(limit) });
setSearchParams(params, { replace: true });
}, [limit, order, range, setSearchParams, sort]);
const columns = useMemo<DataColumn<ReferralRankingItem>[]>(
() => [
{
@@ -117,6 +150,35 @@ export function ReferralsPage() {
/>
</section>
<Card className="overflow-hidden">
<ChartToolbar label="邀请排行设置">
<SortControl
label="排行指标"
value={sort}
order={order}
options={[
{ value: "invited", label: "邀请人数" },
{ value: "qualified", label: "有效邀请" },
{ value: "creditsEarned", label: "奖励积分" },
]}
onChange={(value, nextOrder) => {
setSort(value);
setOrder(nextOrder);
}}
/>
<FilterControl
label="排行数量"
value={String(limit)}
options={[
{ value: "20", label: "前 20 名" },
{ value: "50", label: "前 50 名" },
{ value: "100", label: "前 100 名" },
]}
onChange={(value) => setLimit(Number(value))}
/>
</ChartToolbar>
</Card>
<section className="grid gap-6 xl:grid-cols-[0.85fr_1.15fr]">
<Card className="overflow-hidden">
<div className="flex items-center justify-between border-b border-border p-5 sm:p-6">
@@ -180,6 +242,16 @@ export function ReferralsPage() {
columns={columns}
caption="有效邀请用户排行"
emptyTitle="当前周期暂无排行数据"
sort={{ key: sort, order }}
sortableColumns={{
invited: "invited",
qualified: "qualified",
creditsEarned: "creditsEarned",
}}
onSortChange={(key, nextOrder) => {
setSort(key as NonNullable<ReferralsQuery["sort"]>);
setOrder(nextOrder);
}}
/>
</Card>
+156 -39
View File
@@ -18,6 +18,7 @@ import {
useState,
type FormEvent,
} from "react";
import { useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { adminApi, ApiError } from "../../api/client";
import type {
@@ -25,8 +26,12 @@ import type {
AdminOperatorProvisioning,
AdminRole,
AdminSecuritySummary,
OperatorsQuery,
SortOrder,
} from "../../api/types";
import { DataTable, type DataColumn } from "../../components/data-table";
import { DateRangeControl } from "../../components/date-range-control";
import { FilterControl } from "../../components/filter-control";
import {
Badge,
Button,
@@ -38,6 +43,8 @@ import {
PageHeader,
StatCard,
} from "../../components/primitives";
import { TableToolbar } from "../../components/table-toolbar";
import { useCursorPage } from "../../hooks/use-cursor-page";
import { formatDateTime, formatNumber } from "../../lib/format";
import { useAuth } from "../auth/auth-context";
@@ -51,12 +58,22 @@ interface ConfirmationState {
export function SecurityPage() {
const { auth } = useAuth();
const currentUsername = auth.status === "authenticated" ? auth.operatorName : "";
const [operators, setOperators] = useState<AdminOperator[]>([]);
const [searchParams, setSearchParams] = useSearchParams();
const [summary, setSummary] = useState<AdminSecuritySummary>();
const [nextCursor, setNextCursor] = useState<string>();
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<unknown>();
const [summaryError, setSummaryError] = useState<unknown>();
const [from, setFrom] = useState(searchParams.get("from") ?? "");
const [until, setUntil] = useState(searchParams.get("until") ?? "");
const [role, setRole] = useState<"" | AdminRole>(
(searchParams.get("role") as AdminRole | null) ?? "",
);
const [enabled, setEnabled] = useState(searchParams.get("enabled") ?? "");
const [locked, setLocked] = useState(searchParams.get("locked") ?? "");
const [sort, setSort] = useState<NonNullable<OperatorsQuery["sort"]>>(
(searchParams.get("sort") as OperatorsQuery["sort"] | null) ?? "createdAt",
);
const [order, setOrder] = useState<SortOrder>(
searchParams.get("order") === "asc" ? "asc" : "desc",
);
const [createOpen, setCreateOpen] = useState(false);
const [resetOperator, setResetOperator] = useState<AdminOperator>();
const [confirmation, setConfirmation] = useState<ConfirmationState>();
@@ -64,44 +81,60 @@ export function SecurityPage() {
data: AdminOperatorProvisioning;
title: string;
}>();
const load = useCallback(async () => {
setLoading(true);
setError(undefined);
const operatorsQuery = useMemo<OperatorsQuery>(
() => ({
from: from || undefined,
until: until || undefined,
role: role || undefined,
enabled: enabled === "" ? undefined : enabled === "true",
locked: locked === "" ? undefined : locked === "true",
sort,
order,
limit: 50,
}),
[enabled, from, locked, order, role, sort, until],
);
const fetchOperators = useCallback(
(value: OperatorsQuery) => adminApi.operators(value),
[],
);
const {
items: operators,
nextCursor,
loading,
loadingMore,
error,
loadMore,
reload,
} = useCursorPage(operatorsQuery, fetchOperators);
const loadSummary = useCallback(async () => {
setSummaryError(undefined);
try {
const [page, securitySummary] = await Promise.all([
adminApi.operators(),
adminApi.operatorSummary(),
]);
setOperators(page.items);
setNextCursor(page.nextCursor);
setSummary(securitySummary);
setSummary(await adminApi.operatorSummary());
} catch (requestError) {
setError(requestError);
} finally {
setLoading(false);
setSummaryError(requestError);
}
}, []);
const load = useCallback(async () => {
reload();
await loadSummary();
}, [loadSummary, reload]);
useEffect(() => {
void load();
}, [load]);
void loadSummary();
}, [loadSummary]);
async function loadMore() {
if (!nextCursor) return;
setLoadingMore(true);
try {
const page = await adminApi.operators(nextCursor);
setOperators((current) => [...current, ...page.items]);
setNextCursor(page.nextCursor);
} catch (requestError) {
toast.error(
requestError instanceof ApiError ? requestError.message : "加载管理员失败",
);
} finally {
setLoadingMore(false);
}
}
useEffect(() => {
const params = new URLSearchParams();
if (from) params.set("from", from);
if (until) params.set("until", until);
if (role) params.set("role", role);
if (enabled) params.set("enabled", enabled);
if (locked) params.set("locked", locked);
params.set("sort", sort);
params.set("order", order);
setSearchParams(params, { replace: true });
}, [enabled, from, locked, order, role, setSearchParams, sort, until]);
async function handleConfirmedAction() {
if (!confirmation) return;
@@ -129,7 +162,7 @@ export function SecurityPage() {
const columns = useMemo<DataColumn<AdminOperator>[]>(
() => [
{
id: "operator",
id: "username",
header: "管理员",
cell: ({ row }) => {
const current = row.original.username === currentUsername;
@@ -177,6 +210,15 @@ export function SecurityPage() {
</span>
),
},
{
accessorKey: "createdAt",
header: "创建时间",
cell: ({ getValue }) => (
<span className="whitespace-nowrap text-xs text-muted">
{formatDateTime(String(getValue()))}
</span>
),
},
{
id: "actions",
header: "操作",
@@ -193,7 +235,9 @@ export function SecurityPage() {
[currentUsername],
);
if (error) return <ErrorState error={error} retry={() => void load()} />;
if (error || summaryError) {
return <ErrorState error={error ?? summaryError} retry={() => void load()} />;
}
if (loading || !summary) return <LoadingState label="加载安全中心" />;
return (
@@ -258,11 +302,84 @@ export function SecurityPage() {
</div>
<KeyRound className="size-5 text-muted" aria-hidden />
</div>
<TableToolbar
active={Boolean(
from ||
until ||
role ||
enabled ||
locked ||
sort !== "createdAt" ||
order !== "desc",
)}
onClear={() => {
setFrom("");
setUntil("");
setRole("");
setEnabled("");
setLocked("");
setSort("createdAt");
setOrder("desc");
}}
>
<DateRangeControl
value={{ from: from || undefined, until: until || undefined }}
onChange={(value) => {
setFrom(value.from ?? "");
setUntil(value.until ?? "");
}}
/>
<FilterControl
label="角色"
value={role}
options={[
{ value: "", label: "全部角色" },
{ value: "SUPER_ADMIN", label: "超级管理员" },
{ value: "SUPPORT", label: "支持人员" },
{ value: "ANALYST", label: "分析员" },
]}
onChange={setRole}
/>
<FilterControl
label="启用状态"
value={enabled}
options={[
{ value: "", label: "全部" },
{ value: "true", label: "已启用" },
{ value: "false", label: "已停用" },
]}
onChange={setEnabled}
/>
<FilterControl
label="锁定状态"
value={locked}
options={[
{ value: "", label: "全部" },
{ value: "true", label: "已锁定" },
{ value: "false", label: "未锁定" },
]}
onChange={setLocked}
/>
</TableToolbar>
<DataTable
data={operators}
columns={columns}
caption="管理员账户与安全状态"
emptyTitle="暂无管理员账户"
emptyTitle={
from || until || role || enabled || locked
? "没有符合当前筛选条件的管理员"
: "暂无管理员账户"
}
sort={{ key: sort, order }}
sortableColumns={{
username: "username",
lastLoginAt: "lastLoginAt",
createdAt: "createdAt",
}}
onSortChange={(key, nextOrder) => {
setSort(key as NonNullable<OperatorsQuery["sort"]>);
setOrder(nextOrder);
}}
footer={
nextCursor ? (
<div className="flex justify-center border-t border-border p-5">
+175 -47
View File
@@ -11,18 +11,26 @@ import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type FormEvent,
} from "react";
import { useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { adminApi, ApiError } from "../../api/client";
import type {
LedgerEntryType,
LedgerQuery,
LedgerEntry,
SortOrder,
UserDetail,
UsersQuery,
UserSummary,
UserUsageAggregate,
} from "../../api/types";
import { DataTable, type DataColumn } from "../../components/data-table";
import { DateRangeControl } from "../../components/date-range-control";
import { FilterControl } from "../../components/filter-control";
import {
Badge,
Button,
@@ -34,6 +42,8 @@ import {
PageHeader,
Textarea,
} from "../../components/primitives";
import { TableToolbar } from "../../components/table-toolbar";
import { useCursorPage } from "../../hooks/use-cursor-page";
import {
createIdempotencyKey,
formatDateTime,
@@ -44,6 +54,15 @@ import {
} from "../../lib/format";
import { useAuth } from "../auth/auth-context";
const ledgerTypeOptions: Array<{ value: "" | LedgerEntryType; label: string }> = [
{ value: "", label: "全部类型" },
{ value: "grant", label: "赠送" },
{ value: "reserve", label: "预留" },
{ value: "settle", label: "结算" },
{ value: "refund", label: "退款" },
{ value: "adjustment", label: "调整" },
];
export function UsersPage() {
const { auth } = useAuth();
const role = auth.status === "authenticated" ? auth.role : "ANALYST";
@@ -61,51 +80,56 @@ export function UsersPage() {
}
function UserList({ onSelect }: { onSelect: (userId: string) => void }) {
const [query, setQuery] = useState("");
const [activeQuery, setActiveQuery] = useState("");
const [items, setItems] = useState<UserSummary[]>([]);
const [nextCursor, setNextCursor] = useState<string>();
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<unknown>();
const load = useCallback(async (search = "") => {
setLoading(true);
setError(undefined);
try {
const page = await adminApi.users(search);
setItems(page.items);
setNextCursor(page.nextCursor);
} catch (requestError) {
setError(requestError);
} finally {
setLoading(false);
}
}, []);
const [searchParams, setSearchParams] = useSearchParams();
const initialQuery = searchParams.get("q") ?? "";
const [query, setQuery] = useState(initialQuery);
const [activeQuery, setActiveQuery] = useState(initialQuery);
const [from, setFrom] = useState(searchParams.get("from") ?? "");
const [until, setUntil] = useState(searchParams.get("until") ?? "");
const [status, setStatus] = useState<"" | UserSummary["status"]>(
(searchParams.get("status") as UserSummary["status"] | null) ?? "",
);
const [order, setOrder] = useState<SortOrder>(
searchParams.get("order") === "asc" ? "asc" : "desc",
);
const usersQuery = useMemo<UsersQuery>(
() => ({
q: activeQuery || undefined,
from: from || undefined,
until: until || undefined,
status: status || undefined,
sort: "createdAt",
order,
limit: 50,
}),
[activeQuery, from, order, status, until],
);
const fetchUsers = useCallback((value: UsersQuery) => adminApi.users(value), []);
const {
items,
nextCursor,
loading,
loadingMore,
error,
loadMore,
reload,
} = useCursorPage(usersQuery, fetchUsers);
useEffect(() => {
void load();
}, [load]);
const params = new URLSearchParams();
if (activeQuery) params.set("q", activeQuery);
if (from) params.set("from", from);
if (until) params.set("until", until);
if (status) params.set("status", status);
params.set("sort", "createdAt");
params.set("order", order);
setSearchParams(params, { replace: true });
}, [activeQuery, from, order, setSearchParams, status, until]);
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const search = query.trim();
setActiveQuery(search);
void load(search);
}
async function loadMore() {
if (!nextCursor) return;
setLoadingMore(true);
try {
const page = await adminApi.users(activeQuery, nextCursor);
setItems((current) => [...current, ...page.items]);
setNextCursor(page.nextCursor);
} catch (requestError) {
toast.error(requestError instanceof ApiError ? requestError.message : "加载用户失败");
} finally {
setLoadingMore(false);
}
}
const columns = useMemo<DataColumn<UserSummary>[]>(
@@ -201,6 +225,35 @@ function UserList({ onSelect }: { onSelect: (userId: string) => void }) {
</Button>
</form>
</div>
<TableToolbar
active={Boolean(activeQuery || from || until || status || order !== "desc")}
onClear={() => {
setQuery("");
setActiveQuery("");
setFrom("");
setUntil("");
setStatus("");
setOrder("desc");
}}
>
<DateRangeControl
value={{ from: from || undefined, until: until || undefined }}
onChange={(value) => {
setFrom(value.from ?? "");
setUntil(value.until ?? "");
}}
/>
<FilterControl
label="账户状态"
value={status}
options={[
{ value: "", label: "全部状态" },
{ value: "active", label: "正常" },
{ value: "suspended", label: "已暂停" },
]}
onChange={setStatus}
/>
</TableToolbar>
<div className="flex items-center gap-2 border-b border-border bg-surface-muted/35 px-5 py-3 text-xs text-muted sm:px-6">
<Users className="size-4 text-primary" aria-hidden />
{activeQuery ? "查询结果" : "全部用户"}
@@ -209,7 +262,7 @@ function UserList({ onSelect }: { onSelect: (userId: string) => void }) {
</div>
{error ? (
<div className="p-6">
<ErrorState error={error} retry={() => void load(activeQuery)} />
<ErrorState error={error} retry={reload} />
</div>
) : loading ? (
<LoadingState label="加载用户列表" />
@@ -218,7 +271,14 @@ function UserList({ onSelect }: { onSelect: (userId: string) => void }) {
data={items}
columns={columns}
caption={activeQuery ? "用户查询结果" : "全部用户"}
emptyTitle="未找到匹配用户"
emptyTitle={
activeQuery || from || until || status
? "没有符合当前筛选条件的用户"
: "暂无用户"
}
sort={{ key: "createdAt", order }}
sortableColumns={{ createdAt: "createdAt" }}
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
footer={
nextCursor ? (
<div className="flex justify-center border-t border-border p-5">
@@ -244,6 +304,7 @@ function UserDetailView({
canGrant: boolean;
onBack: () => void;
}) {
const [searchParams, setSearchParams] = useSearchParams();
const [user, setUser] = useState<UserDetail>();
const [ledger, setLedger] = useState<LedgerEntry[]>([]);
const [nextCursor, setNextCursor] = useState<string>();
@@ -251,34 +312,75 @@ function UserDetailView({
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<unknown>();
const [grantOpen, setGrantOpen] = useState(false);
const [from, setFrom] = useState(searchParams.get("ledgerFrom") ?? "");
const [until, setUntil] = useState(searchParams.get("ledgerUntil") ?? "");
const [type, setType] = useState<"" | LedgerEntryType>(
(searchParams.get("ledgerType") as LedgerEntryType | null) ?? "",
);
const [order, setOrder] = useState<SortOrder>(
searchParams.get("ledgerOrder") === "asc" ? "asc" : "desc",
);
const requestVersion = useRef(0);
const ledgerQuery = useMemo<LedgerQuery>(
() => ({
from: from || undefined,
until: until || undefined,
type: type || undefined,
sort: "createdAt",
order,
limit: 50,
}),
[from, order, type, until],
);
const load = useCallback(async () => {
const version = ++requestVersion.current;
setLedger([]);
setNextCursor(undefined);
setLoading(true);
setLoadingMore(false);
setError(undefined);
try {
const [detail, page] = await Promise.all([
adminApi.user(userId),
adminApi.ledger(userId),
adminApi.ledger(userId, ledgerQuery),
]);
if (requestVersion.current !== version) return;
setUser(detail);
setLedger(page.items);
setNextCursor(page.nextCursor);
} catch (requestError) {
setError(requestError);
if (requestVersion.current === version) setError(requestError);
} finally {
setLoading(false);
if (requestVersion.current === version) setLoading(false);
}
}, [userId]);
}, [ledgerQuery, userId]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
setSearchParams((current) => {
const params = new URLSearchParams(current);
["ledgerFrom", "ledgerUntil", "ledgerType", "ledgerOrder"].forEach((key) =>
params.delete(key),
);
if (from) params.set("ledgerFrom", from);
if (until) params.set("ledgerUntil", until);
if (type) params.set("ledgerType", type);
params.set("ledgerOrder", order);
return params;
}, { replace: true });
}, [from, order, setSearchParams, type, until]);
async function loadMoreLedger() {
if (!nextCursor) return;
const version = requestVersion.current;
setLoadingMore(true);
try {
const page = await adminApi.ledger(userId, nextCursor);
const page = await adminApi.ledger(userId, { ...ledgerQuery, cursor: nextCursor });
if (requestVersion.current !== version) return;
setLedger((current) => [...current, ...page.items]);
setNextCursor(page.nextCursor);
} catch (requestError) {
@@ -286,7 +388,7 @@ function UserDetailView({
requestError instanceof ApiError ? requestError.message : "加载积分流水失败",
);
} finally {
setLoadingMore(false);
if (requestVersion.current === version) setLoadingMore(false);
}
}
@@ -370,11 +472,37 @@ function UserDetailView({
title="积分流水"
description="所有变动均来自不可变账本"
/>
<TableToolbar
active={Boolean(from || until || type || order !== "desc")}
onClear={() => {
setFrom("");
setUntil("");
setType("");
setOrder("desc");
}}
>
<DateRangeControl
value={{ from: from || undefined, until: until || undefined }}
onChange={(value) => {
setFrom(value.from ?? "");
setUntil(value.until ?? "");
}}
/>
<FilterControl
label="流水类型"
value={type}
options={ledgerTypeOptions}
onChange={setType}
/>
</TableToolbar>
<DataTable
data={ledger}
columns={ledgerColumns}
caption={`${user.displayName || user.userId} 的积分流水`}
emptyTitle="暂无积分流水"
emptyTitle={from || until || type ? "没有符合当前筛选条件的流水" : "暂无积分流水"}
sort={{ key: "createdAt", order }}
sortableColumns={{ createdAt: "createdAt" }}
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
footer={
nextCursor ? (
<div className="flex justify-center border-t border-border p-5">
+62
View File
@@ -0,0 +1,62 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { PageResult } from "../api/types";
export function useCursorPage<T, Q extends { cursor?: string }>(
query: Q,
fetchPage: (query: Q) => Promise<PageResult<T>>,
) {
const [items, setItems] = useState<T[]>([]);
const [nextCursor, setNextCursor] = useState<string>();
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<unknown>();
const [refreshKey, setRefreshKey] = useState(0);
const requestVersion = useRef(0);
useEffect(() => {
const version = ++requestVersion.current;
setItems([]);
setNextCursor(undefined);
setLoading(true);
setLoadingMore(false);
setError(undefined);
void fetchPage(query)
.then((page) => {
if (requestVersion.current !== version) return;
setItems(page.items);
setNextCursor(page.nextCursor);
})
.catch((requestError) => {
if (requestVersion.current === version) setError(requestError);
})
.finally(() => {
if (requestVersion.current === version) setLoading(false);
});
}, [fetchPage, query, refreshKey]);
const loadMore = useCallback(async () => {
if (!nextCursor || loadingMore) return;
const version = requestVersion.current;
setLoadingMore(true);
try {
const page = await fetchPage({ ...query, cursor: nextCursor });
if (requestVersion.current !== version) return;
setItems((current) => [...current, ...page.items]);
setNextCursor(page.nextCursor);
} catch (requestError) {
if (requestVersion.current === version) setError(requestError);
} finally {
if (requestVersion.current === version) setLoadingMore(false);
}
}, [fetchPage, loadingMore, nextCursor, query]);
return {
items,
nextCursor,
loading,
loadingMore,
error,
loadMore,
reload: useCallback(() => setRefreshKey((current) => current + 1), []),
};
}
+21
View File
@@ -0,0 +1,21 @@
import type { SortOrder } from "../api/types";
export function stableSort<T>(
items: readonly T[],
value: (item: T) => number | string,
order: SortOrder,
): T[] {
const direction = order === "asc" ? 1 : -1;
return items
.map((item, index) => ({ item, index }))
.sort((left, right) => {
const leftValue = value(left.item);
const rightValue = value(right.item);
const compared =
typeof leftValue === "string" && typeof rightValue === "string"
? leftValue.localeCompare(rightValue)
: Number(leftValue) - Number(rightValue);
return compared === 0 ? left.index - right.index : compared * direction;
})
.map(({ item }) => item);
}
+36
View File
@@ -105,6 +105,42 @@ describe("adminApi", () => {
);
});
it("typed query 编码日期、筛选、排序与布尔值", async () => {
const fetchMock = vi.fn().mockImplementation(async () =>
new Response(JSON.stringify({ items: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
await adminApi.users({
q: "user + value",
from: "2026-08-01T00:00:00.000Z",
until: "2026-08-20T23:59:59.999Z",
status: "suspended",
sort: "createdAt",
order: "asc",
limit: 25,
});
await adminApi.operators({
enabled: false,
locked: true,
sort: "username",
order: "desc",
});
expect(fetchMock.mock.calls[0]?.[0]).toContain(
"q=user+%2B+value&from=2026-08-01T00%3A00%3A00.000Z",
);
expect(fetchMock.mock.calls[0]?.[0]).toContain(
"status=suspended&sort=createdAt&order=asc&limit=25",
);
expect(fetchMock.mock.calls[1]?.[0]).toBe(
"/v1/admin/operators?enabled=false&locked=true&sort=username&order=desc",
);
});
it("缺少 CSRF 时在发送变更请求前失败", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
+63
View File
@@ -0,0 +1,63 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useState } from "react";
import { DataTable, type DataColumn } from "../components/data-table";
import { DateRangeControl } from "../components/date-range-control";
import type { SortOrder } from "../api/types";
import { stableSort } from "../lib/sort";
afterEach(cleanup);
describe("筛选与排序基础能力", () => {
it("DataTable 暴露受控 aria-sort 并通过表头切换方向", async () => {
function TableHarness() {
const [order, setOrder] = useState<SortOrder>("desc");
const columns: DataColumn<{ createdAt: string }>[] = [
{ accessorKey: "createdAt", header: "时间" },
];
return (
<DataTable
data={[{ createdAt: "2026-08-20T00:00:00Z" }]}
columns={columns}
caption="排序测试"
sort={{ key: "createdAt", order }}
sortableColumns={{ createdAt: "createdAt" }}
onSortChange={(_, nextOrder) => setOrder(nextOrder)}
/>
);
}
render(<TableHarness />);
const header = screen.getByRole("columnheader", { name: /时间/ });
expect(header.getAttribute("aria-sort")).toBe("descending");
await userEvent.click(screen.getByRole("button", { name: /时间/ }));
expect(header.getAttribute("aria-sort")).toBe("ascending");
});
it("稳定排序不修改输入并保留同值项目顺序", () => {
const source = [
{ id: "first", value: 2 },
{ id: "second", value: 2 },
{ id: "third", value: 1 },
];
const result = stableSort(source, (item) => item.value, "desc");
expect(result.map((item) => item.id)).toEqual(["first", "second", "third"]);
expect(source.map((item) => item.id)).toEqual(["first", "second", "third"]);
expect(result).not.toBe(source);
});
it("日期范围将结束日期转换为次日 UTC 半开边界", async () => {
const onChange = vi.fn();
render(<DateRangeControl value={{}} onChange={onChange} />);
await userEvent.type(screen.getByLabelText("结束日期"), "2026-08-20");
expect(onChange).toHaveBeenLastCalledWith({
until: "2026-08-21T00:00:00.000Z",
});
});
});
+68 -2
View File
@@ -36,12 +36,49 @@ describe("React 管理页面", () => {
await userEvent.click(screen.getByRole("button", { name: "加载更多用户" }));
await waitFor(() => {
expect(adminApi.users).toHaveBeenNthCalledWith(2, "", "user-next");
expect(adminApi.users).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ cursor: "user-next", sort: "createdAt", order: "desc" }),
);
});
expect(await screen.findByText("第二位用户")).toBeTruthy();
expect(screen.queryByRole("link", { name: /安全中心/ })).toBeNull();
});
it("用户排序变更会清空旧游标并重新请求", async () => {
mockSession("SUPPORT");
vi.spyOn(adminApi, "users")
.mockResolvedValueOnce({
items: [user("第一页", userId)],
nextCursor: "stale-cursor",
})
.mockResolvedValueOnce({
items: [user("旧游标结果", "22222222-2222-4222-8222-222222222222")],
})
.mockResolvedValueOnce({
items: [user("升序结果", "33333333-3333-4333-8333-333333333333")],
});
window.location.hash = "#/users";
render(<App />);
expect(await screen.findByText("第一页")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: "加载更多用户" }));
expect(await screen.findByText("旧游标结果")).toBeTruthy();
await userEvent.click(screen.getByRole("button", { name: /注册时间/ }));
await waitFor(() => {
expect(adminApi.users).toHaveBeenCalledTimes(3);
const refreshedQuery = vi.mocked(adminApi.users).mock.calls[2]?.[0];
expect(refreshedQuery).toMatchObject({ order: "asc" });
expect(refreshedQuery).not.toHaveProperty("cursor");
});
expect(await screen.findByText("升序结果")).toBeTruthy();
expect(screen.queryByText("旧游标结果")).toBeNull();
expect(window.location.hash).toContain("order=asc");
expect(window.location.hash).not.toContain("cursor");
});
it("积分页自动显示最新不可变流水", async () => {
mockSession("SUPPORT");
const entry: LedgerEntry = {
@@ -81,7 +118,10 @@ describe("React 管理页面", () => {
await userEvent.click(screen.getByRole("button", { name: "加载更多管理员" }));
await waitFor(() => {
expect(adminApi.operators).toHaveBeenNthCalledWith(2, "operator-next");
expect(adminApi.operators).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ cursor: "operator-next", sort: "createdAt", order: "desc" }),
);
});
expect(await screen.findByText("support")).toBeTruthy();
expect(screen.getByText("已加载 2 个账户")).toBeTruthy();
@@ -101,6 +141,32 @@ describe("React 管理页面", () => {
expect(screen.getByText("文字润色")).toBeTruthy();
expect(screen.getByText("7 天免费转付费")).toBeTruthy();
});
it("产品图表可按执行模式筛选并按用户数稳定排序", async () => {
mockSession("SUPPORT");
const overview = analyticsOverview();
overview.aiFeatures = [
{ feature: "POLISH", executionMode: "MANAGED", users: 10, successes: 100 },
{ feature: "AI_ASSISTANT", executionMode: "MANAGED", users: 40, successes: 50 },
{ feature: "HOTWORD", executionMode: "LOCAL", users: 80, successes: 200 },
];
vi.spyOn(adminApi, "productAnalytics").mockResolvedValue(overview);
window.location.hash = "#/analytics";
render(<App />);
expect(await screen.findByText("快捷指令")).toBeTruthy();
await userEvent.selectOptions(screen.getByLabelText("执行模式"), "MANAGED");
expect(screen.queryByText("快捷指令")).toBeNull();
await userEvent.selectOptions(screen.getByDisplayValue("成功次数"), "users");
const labels = screen
.getAllByRole("progressbar")
.map((element) => element.getAttribute("aria-label"));
expect(labels.indexOf("AI 助手 成功次数:50")).toBeLessThan(
labels.indexOf("文字润色 成功次数:100"),
);
});
});
function mockSession(role: "SUPER_ADMIN" | "SUPPORT") {
+102 -9
View File
@@ -593,13 +593,20 @@ paths:
summary: Return referral funnel and ranking statistics
parameters:
- $ref: "#/components/parameters/AdminRange"
- name: sort
in: query
schema: { type: string, enum: [invited, qualified, creditsEarned] }
- $ref: "#/components/parameters/AdminSortOrder"
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
responses:
"200":
description: Referral statistics
content:
application/json:
schema: { $ref: "#/components/schemas/AdminReferralOverview" }
"400": { description: Range is invalid }
"400": { description: Range, sort, order, or limit is invalid }
"401": { description: Session is invalid }
/v1/admin/analytics:
get:
@@ -631,13 +638,20 @@ paths:
in: query
schema: { type: string, maxLength: 256 }
- $ref: "#/components/parameters/Limit"
- $ref: "#/components/parameters/AdminFrom"
- $ref: "#/components/parameters/AdminUntil"
- name: status
in: query
schema: { type: string, enum: [active, suspended] }
- $ref: "#/components/parameters/AdminCreatedAtSort"
- $ref: "#/components/parameters/AdminSortOrder"
responses:
"200":
description: Privacy-minimized user summaries
content:
application/json:
schema: { $ref: "#/components/schemas/AdminUserPage" }
"400": { description: Query or cursor is malformed }
"400": { description: Query, filter, sort, limit, or cursor is malformed }
"401": { description: Session is invalid }
"403": { description: ANALYST role cannot access user records }
/v1/admin/users/{userId}:
@@ -667,14 +681,19 @@ paths:
- name: cursor
in: query
schema: { type: string, maxLength: 256 }
- $ref: "#/components/parameters/Limit"
- $ref: "#/components/parameters/AdminLedgerLimit"
- $ref: "#/components/parameters/AdminFrom"
- $ref: "#/components/parameters/AdminUntil"
- $ref: "#/components/parameters/AdminLedgerType"
- $ref: "#/components/parameters/AdminCreatedAtSort"
- $ref: "#/components/parameters/AdminSortOrder"
responses:
"200":
description: Credit ledger entries ordered by creation time and entry ID
content:
application/json:
schema: { $ref: "#/components/schemas/AdminLedgerPage" }
"400": { description: Cursor is malformed }
"400": { description: Filter, sort, limit, or cursor is malformed }
"403": { description: ANALYST role cannot access credit ledger records }
"404": { description: User was not found }
/v1/admin/credits/ledger:
@@ -687,14 +706,19 @@ paths:
- name: cursor
in: query
schema: { type: string, maxLength: 256 }
- $ref: "#/components/parameters/Limit"
- $ref: "#/components/parameters/AdminLedgerLimit"
- $ref: "#/components/parameters/AdminFrom"
- $ref: "#/components/parameters/AdminUntil"
- $ref: "#/components/parameters/AdminLedgerType"
- $ref: "#/components/parameters/AdminCreatedAtSort"
- $ref: "#/components/parameters/AdminSortOrder"
responses:
"200":
description: Latest credit ledger entries ordered by creation time and entry ID
content:
application/json:
schema: { $ref: "#/components/schemas/AdminLedgerPage" }
"400": { description: Cursor is malformed }
"400": { description: Filter, sort, limit, or cursor is malformed }
"403": { description: ANALYST role cannot access credit ledger records }
/v1/admin/credits/grants:
post:
@@ -758,14 +782,33 @@ paths:
in: query
schema: { type: string, maxLength: 256 }
- $ref: "#/components/parameters/Limit"
- $ref: "#/components/parameters/AdminFrom"
- $ref: "#/components/parameters/AdminUntil"
- name: role
in: query
schema: { type: string, enum: [SUPER_ADMIN, SUPPORT, ANALYST] }
- name: enabled
in: query
schema: { type: boolean }
- name: locked
in: query
description: Whether locked_until is later than the request time.
schema: { type: boolean }
- name: sort
in: query
schema:
type: string
enum: [createdAt, username, lastLoginAt]
default: createdAt
- $ref: "#/components/parameters/AdminSortOrder"
responses:
"200":
description: Operators ordered by creation time
description: Operators ordered by the requested stable sort and operator ID; null lastLoginAt values are last
content:
application/json:
schema: { $ref: "#/components/schemas/AdminOperatorPage" }
"403": { description: INSUFFICIENT_PERMISSION; SUPER_ADMIN is required }
"400": { description: Cursor or limit is malformed }
"400": { description: Filter, sort, limit, or cursor is malformed }
post:
security:
- adminMtls: []
@@ -874,13 +917,35 @@ paths:
in: query
schema: { type: string, maxLength: 256 }
- $ref: "#/components/parameters/Limit"
- $ref: "#/components/parameters/AdminFrom"
- $ref: "#/components/parameters/AdminUntil"
- name: action
in: query
schema:
type: string
enum:
- LOGIN_SUCCEEDED
- LOGIN_FAILED
- SESSION_REVOKED
- OPERATOR_CREATED
- OPERATOR_ENABLED
- OPERATOR_DISABLED
- OPERATOR_UNLOCKED
- OPERATOR_CREDENTIALS_RESET
- OPERATOR_SESSIONS_REVOKED
- MANUAL_CREDIT_GRANTED
- name: result
in: query
schema: { type: string, enum: [success, rejected] }
- $ref: "#/components/parameters/AdminCreatedAtSort"
- $ref: "#/components/parameters/AdminSortOrder"
responses:
"200":
description: Recent audit events
content:
application/json:
schema: { $ref: "#/components/schemas/AdminAuditPage" }
"400": { description: Cursor is malformed }
"400": { description: Filter, sort, limit, or cursor is malformed }
"403": { description: Super-administrator role is required }
components:
securitySchemes:
@@ -919,6 +984,34 @@ components:
name: range
in: query
schema: { type: string, enum: [7d, 30d, 90d], default: 30d }
AdminFrom:
name: from
in: query
description: Inclusive UTC lower bound. When both bounds are present, from must be earlier than until.
schema: { type: string, format: date-time }
AdminUntil:
name: until
in: query
description: Exclusive UTC upper bound.
schema: { type: string, format: date-time }
AdminCreatedAtSort:
name: sort
in: query
schema: { type: string, enum: [createdAt], default: createdAt }
AdminSortOrder:
name: order
in: query
schema: { type: string, enum: [asc, desc] }
AdminLedgerType:
name: type
in: query
schema:
type: string
enum: [grant, reserve, settle, refund, adjustment]
AdminLedgerLimit:
name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 100 }
AdminUserId:
name: userId
in: path
@@ -20,11 +20,6 @@ data class AdminOperatorRecord(
val updatedAt: Instant,
)
data class AdminOperatorCursor(
val createdAt: Instant,
val id: UUID,
)
data class NewAdminOperator(
val id: UUID,
val normalizedUsername: String,
@@ -0,0 +1,48 @@
package com.osglab.account.features.admin.models
import java.time.Instant
import java.util.UUID
enum class AdminSortOrder {
ASC,
DESC,
}
data class AdminTimeFilter(
val from: Instant? = null,
val until: Instant? = null,
) {
init {
require(from == null || until == null || from < until) {
"Admin query time range must be non-empty"
}
}
}
data class AdminAuditQuery(
val time: AdminTimeFilter = AdminTimeFilter(),
val action: AdminAuditAction? = null,
val outcome: AdminAuditOutcome? = null,
val order: AdminSortOrder = AdminSortOrder.DESC,
)
enum class AdminOperatorSort {
CREATED_AT,
USERNAME,
LAST_LOGIN_AT,
}
data class AdminOperatorQuery(
val time: AdminTimeFilter = AdminTimeFilter(),
val role: AdminRole? = null,
val enabled: Boolean? = null,
val locked: Boolean? = null,
val sort: AdminOperatorSort = AdminOperatorSort.CREATED_AT,
val order: AdminSortOrder = AdminSortOrder.ASC,
val now: Instant,
)
data class AdminOperatorCursor(
val value: String?,
val id: UUID,
)
@@ -1,6 +1,7 @@
package com.osglab.account.features.admin.repositories
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.models.AdminAuditQuery
import com.osglab.account.features.admin.models.AdminAuditCursor
import com.osglab.account.features.admin.models.AdminLockState
import com.osglab.account.features.admin.models.AdminAuditAction
@@ -8,10 +9,13 @@ import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminAuditRecord
import com.osglab.account.features.admin.models.AdminOperatorAuthRecord
import com.osglab.account.features.admin.models.AdminOperatorCursor
import com.osglab.account.features.admin.models.AdminOperatorQuery
import com.osglab.account.features.admin.models.AdminOperatorSort
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
import com.osglab.account.features.admin.models.AdminOperatorRecord
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminSessionRecord
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.models.NewAdminSession
@@ -21,6 +25,7 @@ import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.greater
import org.jetbrains.exposed.v1.core.greaterEq
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.isNotNull
import org.jetbrains.exposed.v1.core.isNull
@@ -86,6 +91,7 @@ interface AdminRepository {
suspend fun listOperatorsPage(
limit: Int,
before: AdminOperatorCursor? = null,
query: AdminOperatorQuery,
): List<AdminOperatorRecord>
suspend fun countActiveSessions(now: Instant): Long
suspend fun findOperator(operatorId: UUID): AdminOperatorRecord?
@@ -148,6 +154,7 @@ interface AdminRepository {
suspend fun listAudit(
limit: Int,
before: AdminAuditCursor? = null,
query: AdminAuditQuery = AdminAuditQuery(),
): List<AdminAuditRecord>
}
@@ -185,24 +192,119 @@ class ExposedAdminRepository(
override suspend fun listOperatorsPage(
limit: Int,
before: AdminOperatorCursor?,
query: AdminOperatorQuery,
): List<AdminOperatorRecord> =
databaseFactory.query {
require(limit in 1..101)
val query = AdminOperatorsTable.selectAll()
if (before != null) {
query.andWhere {
(AdminOperatorsTable.createdAt greater before.createdAt) or
(
(AdminOperatorsTable.createdAt eq before.createdAt) and
(AdminOperatorsTable.id greater before.id.toString())
)
val statement = AdminOperatorsTable.selectAll()
query.time.from?.let { from ->
statement.andWhere { AdminOperatorsTable.createdAt greaterEq from }
}
query.time.until?.let { until ->
statement.andWhere { AdminOperatorsTable.createdAt less until }
}
query.role?.let { role ->
statement.andWhere { AdminOperatorsTable.role eq role.name }
}
query.enabled?.let { enabled ->
statement.andWhere {
if (enabled) AdminOperatorsTable.disabledAt.isNull()
else AdminOperatorsTable.disabledAt.isNotNull()
}
}
query
.orderBy(
AdminOperatorsTable.createdAt to SortOrder.ASC,
AdminOperatorsTable.id to SortOrder.ASC,
query.locked?.let { locked ->
statement.andWhere {
if (locked) {
AdminOperatorsTable.lockedUntil.isNotNull() and
(AdminOperatorsTable.lockedUntil greater query.now)
} else {
AdminOperatorsTable.lockedUntil.isNull() or
(AdminOperatorsTable.lockedUntil lessEq query.now)
}
}
}
if (before != null) {
statement.andWhere {
val ascending = query.order == AdminSortOrder.ASC
when (query.sort) {
AdminOperatorSort.CREATED_AT -> {
val value = Instant.parse(requireNotNull(before.value))
if (ascending) {
(AdminOperatorsTable.createdAt greater value) or
(
(AdminOperatorsTable.createdAt eq value) and
(AdminOperatorsTable.id greater before.id.toString())
)
} else {
(AdminOperatorsTable.createdAt less value) or
(
(AdminOperatorsTable.createdAt eq value) and
(AdminOperatorsTable.id less before.id.toString())
)
}
}
AdminOperatorSort.USERNAME -> {
val value = requireNotNull(before.value)
if (ascending) {
(AdminOperatorsTable.username greater value) or
(
(AdminOperatorsTable.username eq value) and
(AdminOperatorsTable.id greater before.id.toString())
)
} else {
(AdminOperatorsTable.username less value) or
(
(AdminOperatorsTable.username eq value) and
(AdminOperatorsTable.id less before.id.toString())
)
}
}
AdminOperatorSort.LAST_LOGIN_AT -> {
val value = before.value?.let(Instant::parse)
if (value == null) {
AdminOperatorsTable.lastLoginAt.isNull() and
if (ascending) {
AdminOperatorsTable.id greater before.id.toString()
} else {
AdminOperatorsTable.id less before.id.toString()
}
} else {
val nonNullAfter = if (ascending) {
(AdminOperatorsTable.lastLoginAt greater value) or
(
(AdminOperatorsTable.lastLoginAt eq value) and
(AdminOperatorsTable.id greater before.id.toString())
)
} else {
(AdminOperatorsTable.lastLoginAt less value) or
(
(AdminOperatorsTable.lastLoginAt eq value) and
(AdminOperatorsTable.id less before.id.toString())
)
}
nonNullAfter or AdminOperatorsTable.lastLoginAt.isNull()
}
}
}
}
}
val sortOrder = query.order.toExposedSortOrder()
when (query.sort) {
AdminOperatorSort.CREATED_AT -> statement.orderBy(
AdminOperatorsTable.createdAt to sortOrder,
AdminOperatorsTable.id to sortOrder,
)
AdminOperatorSort.USERNAME -> statement.orderBy(
AdminOperatorsTable.username to sortOrder,
AdminOperatorsTable.id to sortOrder,
)
AdminOperatorSort.LAST_LOGIN_AT -> statement.orderBy(
AdminOperatorsTable.lastLoginAt.isNull() to SortOrder.ASC,
AdminOperatorsTable.lastLoginAt to sortOrder,
AdminOperatorsTable.id to sortOrder,
)
}
statement
.limit(limit)
.map(ResultRow::toOperatorRecord)
}
@@ -495,23 +597,45 @@ class ExposedAdminRepository(
override suspend fun listAudit(
limit: Int,
before: AdminAuditCursor?,
query: AdminAuditQuery,
): List<AdminAuditRecord> =
databaseFactory.query {
require(limit in 1..101)
val query = AdminAuditLogTable.selectAll()
val statement = AdminAuditLogTable.selectAll()
query.time.from?.let { from ->
statement.andWhere { AdminAuditLogTable.occurredAt greaterEq from }
}
query.time.until?.let { until ->
statement.andWhere { AdminAuditLogTable.occurredAt less until }
}
query.action?.let { action ->
statement.andWhere { AdminAuditLogTable.action eq action.name }
}
query.outcome?.let { outcome ->
statement.andWhere { AdminAuditLogTable.outcome eq outcome.name }
}
if (before != null) {
query.andWhere {
(AdminAuditLogTable.occurredAt less before.occurredAt) or
(
(AdminAuditLogTable.occurredAt eq before.occurredAt) and
(AdminAuditLogTable.id less before.id.toString())
)
statement.andWhere {
if (query.order == AdminSortOrder.ASC) {
(AdminAuditLogTable.occurredAt greater before.occurredAt) or
(
(AdminAuditLogTable.occurredAt eq before.occurredAt) and
(AdminAuditLogTable.id greater before.id.toString())
)
} else {
(AdminAuditLogTable.occurredAt less before.occurredAt) or
(
(AdminAuditLogTable.occurredAt eq before.occurredAt) and
(AdminAuditLogTable.id less before.id.toString())
)
}
}
}
query
val sortOrder = query.order.toExposedSortOrder()
statement
.orderBy(
AdminAuditLogTable.occurredAt to SortOrder.DESC,
AdminAuditLogTable.id to SortOrder.DESC,
AdminAuditLogTable.occurredAt to sortOrder,
AdminAuditLogTable.id to sortOrder,
)
.limit(limit)
.map {
@@ -528,6 +652,9 @@ class ExposedAdminRepository(
}
}
private fun AdminSortOrder.toExposedSortOrder(): SortOrder =
if (this == AdminSortOrder.ASC) SortOrder.ASC else SortOrder.DESC
private fun insertAudit(event: NewAdminAuditEvent) {
AdminAuditLogTable.insert {
it[id] = event.id.toString()
@@ -3,10 +3,17 @@ package com.osglab.account.features.admin.routes
import com.osglab.account.config.AppConfig
import com.osglab.account.features.admin.grants.models.ManualGrantCommand
import com.osglab.account.features.admin.grants.services.AdminGrantService
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminAuditQuery
import com.osglab.account.features.admin.models.AdminLoginResult
import com.osglab.account.features.admin.models.AdminOperatorQuery
import com.osglab.account.features.admin.models.AdminOperatorRecord
import com.osglab.account.features.admin.models.AdminOperatorSort
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminOperatorRecord
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.models.AdminTimeFilter
import com.osglab.account.features.admin.services.AdminAuditCursorException
import com.osglab.account.features.admin.services.AdminAuditService
import com.osglab.account.features.admin.services.AdminAuthService
@@ -19,11 +26,16 @@ import com.osglab.account.features.admin.services.AdminSessionService
import com.osglab.account.features.admin.stats.models.AdminStatsDto
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.admin.stats.services.AdminProductAnalyticsService
import com.osglab.account.features.admin.stats.services.AdminReferralSort
import com.osglab.account.features.admin.stats.services.AdminStatsService
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.models.AdminUserReferralDto
import com.osglab.account.features.admin.users.models.AdminUserSummaryDto
import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery
import com.osglab.account.features.admin.users.repositories.AdminLedgerType
import com.osglab.account.features.admin.users.repositories.AdminUserListQuery
import com.osglab.account.features.admin.users.repositories.AdminUserStatus
import com.osglab.account.features.admin.users.services.AdminUserNotFoundException
import com.osglab.account.features.admin.users.services.AdminUsersService
import com.osglab.account.features.credits.domain.CreditConflict
@@ -51,6 +63,9 @@ import io.ktor.server.routing.route
import kotlinx.serialization.Serializable
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.UUID
fun Route.adminWebRoutes(config: AppConfig) {
@@ -159,7 +174,18 @@ fun Route.adminApiRoutes(
get("/referrals") {
if (call.requirePrincipal(config, sessionService) == null) return@get
val stats = statsService.getRange(call.request.queryParameters["range"], clock)
val options = runCatching { call.adminReferralQuery() }.getOrNull()
?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
val stats = statsService.getRange(
range = call.request.queryParameters["range"],
clock = clock,
referralRankLimit = options.limit,
referralSort = options.sort,
referralOrder = options.order,
)
?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
@@ -185,6 +211,11 @@ fun Route.adminApiRoutes(
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
) == null
) return@get
val listQuery = runCatching { call.adminUserListQuery() }.getOrNull()
?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
val limit = call.pageLimit(maximum = 100) ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
@@ -195,9 +226,10 @@ fun Route.adminApiRoutes(
usersService.list(
limit = limit,
cursor = call.request.queryParameters["cursor"],
query = listQuery,
)
} else {
usersService.searchByInternalId(query)
usersService.searchByInternalId(query, listQuery)
}
} catch (_: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
@@ -236,6 +268,11 @@ fun Route.adminApiRoutes(
) == null
) return@get
val userId = call.uuidPathParameter("userId") ?: return@get
val ledgerQuery = runCatching { call.adminLedgerQuery() }.getOrNull()
?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
val limit = call.pageLimit(maximum = 100, default = 100) ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
@@ -245,6 +282,7 @@ fun Route.adminApiRoutes(
userId = userId,
limit = limit,
cursor = call.request.queryParameters["cursor"],
query = ledgerQuery,
)
call.respond(
PageResponse(
@@ -267,6 +305,11 @@ fun Route.adminApiRoutes(
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
) == null
) return@get
val ledgerQuery = runCatching { call.adminLedgerQuery() }.getOrNull()
?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
val limit = call.pageLimit(maximum = 100, default = 100) ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
@@ -275,6 +318,7 @@ fun Route.adminApiRoutes(
val page = usersService.latestLedger(
limit = limit,
cursor = call.request.queryParameters["cursor"],
query = ledgerQuery,
)
call.respond(
PageResponse(
@@ -353,6 +397,11 @@ fun Route.adminApiRoutes(
get("/operators") {
val principal = call.requirePrincipal(config, sessionService) ?: return@get
val operatorQuery = runCatching { call.adminOperatorQuery(clock.instant()) }.getOrNull()
?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
val limit = call.pageLimit(maximum = 100) ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
@@ -362,6 +411,7 @@ fun Route.adminApiRoutes(
actor = principal,
cursor = call.request.queryParameters["cursor"],
limit = limit,
query = operatorQuery,
)
call.respond(
PageResponse(
@@ -480,6 +530,11 @@ fun Route.adminApiRoutes(
get("/audit") {
val principal = call.requirePrincipal(config, sessionService) ?: return@get
val auditQuery = runCatching { call.adminAuditQuery() }.getOrNull()
?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
val limit = call.pageLimit(maximum = 100) ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
@@ -489,6 +544,7 @@ fun Route.adminApiRoutes(
actor = principal,
cursor = call.request.queryParameters["cursor"],
limit = limit,
query = auditQuery,
)
} catch (exception: AdminOperatorException) {
call.respondOperatorError(exception)
@@ -522,9 +578,18 @@ fun Route.adminApiRoutes(
private suspend fun AdminStatsService.getRange(
range: String?,
clock: Clock,
referralRankLimit: Int = 20,
referralSort: AdminReferralSort? = null,
referralOrder: AdminSortOrder = AdminSortOrder.DESC,
): AdminStatsDto? {
val window = parseAdminStatsRange(range, clock) ?: return null
return get(window.first, window.second)
return get(
from = window.first,
until = window.second,
referralRankLimit = referralRankLimit,
referralSort = referralSort,
referralOrder = referralOrder,
)
}
private fun parseAdminStatsRange(range: String?, clock: Clock): Pair<java.time.Instant, java.time.Instant>? {
@@ -538,6 +603,161 @@ private fun parseAdminStatsRange(range: String?, clock: Clock): Pair<java.time.I
return until.minus(Duration.ofDays(days)) to until
}
private data class AdminReferralQueryOptions(
val sort: AdminReferralSort?,
val order: AdminSortOrder,
val limit: Int,
)
private fun ApplicationCall.adminReferralQuery(): AdminReferralQueryOptions {
requireQueryParameters(setOf("range", "sort", "order", "limit"))
val explicitSort = request.queryParameters["sort"]?.let {
when (it) {
"invited" -> AdminReferralSort.INVITED
"qualified" -> AdminReferralSort.QUALIFIED
"creditsEarned" -> AdminReferralSort.CREDITS_EARNED
else -> throw IllegalArgumentException("Invalid referral sort")
}
}
val orderValue = request.queryParameters["order"]
val order = parseSortOrder(orderValue, AdminSortOrder.DESC)
val limit = request.queryParameters["limit"]?.toIntOrNull()?.takeIf { it in 1..100 } ?: run {
require(request.queryParameters["limit"] == null)
20
}
return AdminReferralQueryOptions(
sort = explicitSort ?: if (orderValue != null) AdminReferralSort.INVITED else null,
order = order,
limit = limit,
)
}
private fun ApplicationCall.adminUserListQuery(): AdminUserListQuery {
requireQueryParameters(setOf("q", "cursor", "limit", "from", "until", "status", "sort", "order"))
request.queryParameters["q"]?.let { require(it.trim().length <= 36) }
requireCreatedAtSort()
val status = request.queryParameters["status"]?.let {
when (it) {
"active" -> AdminUserStatus.ACTIVE
"suspended" -> AdminUserStatus.SUSPENDED
else -> throw IllegalArgumentException("Invalid user status")
}
}
return AdminUserListQuery(
time = adminTimeFilter(),
status = status,
order = parseSortOrder(request.queryParameters["order"], AdminSortOrder.DESC),
)
}
private fun ApplicationCall.adminLedgerQuery(): AdminLedgerQuery {
requireQueryParameters(setOf("cursor", "limit", "from", "until", "type", "sort", "order"))
requireCreatedAtSort()
val type = request.queryParameters["type"]?.let {
when (it) {
"reserve" -> AdminLedgerType.RESERVE
"settle" -> AdminLedgerType.SETTLE
"refund" -> AdminLedgerType.REFUND
"grant" -> AdminLedgerType.GRANT
"adjustment" -> AdminLedgerType.ADJUSTMENT
else -> throw IllegalArgumentException("Invalid ledger type")
}
}
return AdminLedgerQuery(
time = adminTimeFilter(),
type = type,
order = parseSortOrder(request.queryParameters["order"], AdminSortOrder.DESC),
)
}
private fun ApplicationCall.adminOperatorQuery(now: Instant): AdminOperatorQuery {
requireQueryParameters(
setOf("cursor", "limit", "from", "until", "role", "enabled", "locked", "sort", "order"),
)
val role = request.queryParameters["role"]?.let {
runCatching { AdminRole.valueOf(it) }.getOrNull()
?: throw IllegalArgumentException("Invalid operator role")
}
val sort = request.queryParameters["sort"]?.let {
when (it) {
"createdAt" -> AdminOperatorSort.CREATED_AT
"username" -> AdminOperatorSort.USERNAME
"lastLoginAt" -> AdminOperatorSort.LAST_LOGIN_AT
else -> throw IllegalArgumentException("Invalid operator sort")
}
} ?: AdminOperatorSort.CREATED_AT
return AdminOperatorQuery(
time = adminTimeFilter(),
role = role,
enabled = request.queryParameters["enabled"]?.let(::parseStrictBoolean),
locked = request.queryParameters["locked"]?.let(::parseStrictBoolean),
sort = sort,
order = parseSortOrder(request.queryParameters["order"], AdminSortOrder.ASC),
now = now,
)
}
private fun ApplicationCall.adminAuditQuery(): AdminAuditQuery {
requireQueryParameters(
setOf("cursor", "limit", "from", "until", "action", "result", "sort", "order"),
)
requireCreatedAtSort()
val action = request.queryParameters["action"]?.let {
runCatching { AdminAuditAction.valueOf(it) }.getOrNull()
?: throw IllegalArgumentException("Invalid audit action")
}
val outcome = request.queryParameters["result"]?.let {
when (it) {
"success" -> AdminAuditOutcome.SUCCESS
"rejected" -> AdminAuditOutcome.DENIED
else -> throw IllegalArgumentException("Invalid audit result")
}
}
return AdminAuditQuery(
time = adminTimeFilter(),
action = action,
outcome = outcome,
order = parseSortOrder(request.queryParameters["order"], AdminSortOrder.DESC),
)
}
private fun ApplicationCall.adminTimeFilter(): AdminTimeFilter =
AdminTimeFilter(
from = request.queryParameters["from"]?.let(::parseUtcInstant),
until = request.queryParameters["until"]?.let(::parseUtcInstant),
)
private fun parseUtcInstant(value: String): Instant {
val parsed = OffsetDateTime.parse(value)
require(parsed.offset == ZoneOffset.UTC)
return parsed.toInstant()
}
private fun ApplicationCall.requireCreatedAtSort() {
request.queryParameters["sort"]?.let { require(it == "createdAt") }
}
private fun ApplicationCall.requireQueryParameters(allowed: Set<String>) {
val parameters = request.queryParameters
require(parameters.names().all { it in allowed })
require(parameters.names().all { parameters.getAll(it).orEmpty().size == 1 })
}
private fun parseSortOrder(value: String?, default: AdminSortOrder): AdminSortOrder =
when (value) {
null -> default
"asc" -> AdminSortOrder.ASC
"desc" -> AdminSortOrder.DESC
else -> throw IllegalArgumentException("Invalid sort order")
}
private fun parseStrictBoolean(value: String): Boolean =
when (value) {
"true" -> true
"false" -> false
else -> throw IllegalArgumentException("Invalid boolean filter")
}
private suspend fun ApplicationCall.requirePrincipal(
config: AppConfig,
sessions: AdminSessionService,
@@ -1,9 +1,11 @@
package com.osglab.account.features.admin.services
import com.osglab.account.features.admin.models.AdminAuditCursor
import com.osglab.account.features.admin.models.AdminAuditQuery
import com.osglab.account.features.admin.models.AdminAuditRecord
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.repositories.AdminRepository
import java.nio.charset.StandardCharsets
import java.time.Instant
@@ -29,13 +31,14 @@ class AdminAuditService(
actor: AdminPrincipal,
cursor: String?,
limit: Int = DEFAULT_PAGE_SIZE,
query: AdminAuditQuery = AdminAuditQuery(),
): AdminAuditPage {
if (actor.role != AdminRole.SUPER_ADMIN) {
throw AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION)
}
require(limit in 1..MAX_PAGE_SIZE)
val decodedCursor = cursor?.let(::decodeCursor)
val records = repository.listAudit(limit + 1, decodedCursor)
val decodedCursor = cursor?.let { decodeCursor(it, query.order) }
val records = repository.listAudit(limit + 1, decodedCursor, query)
val pageRecords = records.take(limit)
val operatorNames = repository.listOperators().associate {
it.id to it.normalizedUsername
@@ -50,36 +53,41 @@ class AdminAuditService(
)
},
nextCursor = if (records.size > limit) {
pageRecords.lastOrNull()?.let(::encodeCursor)
pageRecords.lastOrNull()?.let { encodeCursor(it, query.order) }
} else {
null
},
)
}
private fun decodeCursor(value: String): AdminAuditCursor {
private fun decodeCursor(value: String, expectedOrder: AdminSortOrder): AdminAuditCursor {
if (value.length !in 1..MAX_CURSOR_LENGTH) throw AdminAuditCursorException()
return runCatching {
val decoded = String(
Base64.getUrlDecoder().decode(value),
StandardCharsets.UTF_8,
)
val parts = decoded.split(':', limit = 3)
require(parts.size == 3)
val parts = decoded.split(':', limit = 5)
require(parts.size == 5)
require(parts[0] == "v1")
require(parts[1] == expectedOrder.name)
AdminAuditCursor(
occurredAt = Instant.ofEpochSecond(
parts[0].toLong(),
parts[1].toLong(),
parts[2].toLong(),
parts[3].toLong(),
),
id = UUID.fromString(parts[2]),
id = UUID.fromString(parts[4]),
)
}.getOrElse {
throw AdminAuditCursorException()
}
}
private fun encodeCursor(record: AdminAuditRecord): String {
private fun encodeCursor(record: AdminAuditRecord, order: AdminSortOrder): String {
val payload = buildString {
append("v1:")
append(order.name)
append(':')
append(record.occurredAt.epochSecond)
append(':')
append(record.occurredAt.nano)
@@ -5,9 +5,12 @@ import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
import com.osglab.account.features.admin.models.AdminOperatorCursor
import com.osglab.account.features.admin.models.AdminOperatorQuery
import com.osglab.account.features.admin.models.AdminOperatorRecord
import com.osglab.account.features.admin.models.AdminOperatorSort
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.repositories.AdminRepository
@@ -15,9 +18,8 @@ import com.osglab.account.features.admin.security.AdminPasswordHasher
import com.osglab.account.features.admin.security.AdminTotpProvisioning
import com.osglab.account.features.admin.security.AdminTotpSecretGenerator
import com.osglab.account.features.admin.security.SecureAdminTotpSecretGenerator
import java.time.Clock
import java.time.Instant
import java.nio.charset.StandardCharsets
import java.time.Clock
import java.util.Base64
import java.util.Locale
import java.util.UUID
@@ -73,17 +75,33 @@ class AdminOperatorService(
actor: AdminPrincipal,
cursor: String?,
limit: Int = DEFAULT_PAGE_SIZE,
): AdminOperatorPage = listPage(
actor = actor,
cursor = cursor,
limit = limit,
query = AdminOperatorQuery(now = clock.instant()),
)
suspend fun listPage(
actor: AdminPrincipal,
cursor: String?,
limit: Int = DEFAULT_PAGE_SIZE,
query: AdminOperatorQuery,
): AdminOperatorPage {
requireSuperAdministrator(actor)
if (limit !in 1..MAX_PAGE_SIZE) {
throw AdminOperatorCursorException()
}
val decodedCursor = cursor?.let(::decodeCursor)
val records = repository.listOperatorsPage(limit + 1, decodedCursor)
val decodedCursor = cursor?.let { decodeCursor(it, query) }
val records = repository.listOperatorsPage(limit + 1, decodedCursor, query)
val items = records.take(limit)
return AdminOperatorPage(
items = items,
nextCursor = if (records.size > limit) items.lastOrNull()?.let(::encodeCursor) else null,
nextCursor = if (records.size > limit) {
items.lastOrNull()?.let { encodeCursor(it, query) }
} else {
null
},
)
}
@@ -370,28 +388,37 @@ class AdminOperatorService(
const val MAX_PASSWORD_CHARS = 1_024
const val MAX_AUDIT_TARGET_CHARS = 128
const val OPERATOR_TARGET = "ADMIN_OPERATOR"
const val NULL_CURSOR_VALUE = "~"
}
private fun decodeCursor(value: String): AdminOperatorCursor {
private fun decodeCursor(value: String, query: AdminOperatorQuery): AdminOperatorCursor {
if (value.length !in 1..MAX_CURSOR_LENGTH) throw AdminOperatorCursorException()
return runCatching {
val decoded = String(
Base64.getUrlDecoder().decode(value),
StandardCharsets.UTF_8,
)
val parts = decoded.split(':', limit = 3)
require(parts.size == 3)
val parts = decoded.split('|')
require(parts.size == 5)
require(parts[0] == "v1")
require(parts[1] == query.sort.name)
require(parts[2] == query.order.name)
AdminOperatorCursor(
createdAt = Instant.ofEpochSecond(parts[0].toLong(), parts[1].toLong()),
id = UUID.fromString(parts[2]),
value = parts[3].takeUnless { it == NULL_CURSOR_VALUE },
id = UUID.fromString(parts[4]),
)
}.getOrElse {
throw AdminOperatorCursorException()
}
}
private fun encodeCursor(record: AdminOperatorRecord): String {
val payload = "${record.createdAt.epochSecond}:${record.createdAt.nano}:${record.id}"
private fun encodeCursor(record: AdminOperatorRecord, query: AdminOperatorQuery): String {
val value = when (query.sort) {
AdminOperatorSort.CREATED_AT -> record.createdAt.toString()
AdminOperatorSort.USERNAME -> record.normalizedUsername
AdminOperatorSort.LAST_LOGIN_AT -> record.lastLoginAt?.toString() ?: NULL_CURSOR_VALUE
}
val payload = "v1|${query.sort.name}|${query.order.name}|$value|${record.id}"
return Base64.getUrlEncoder().withoutPadding().encodeToString(
payload.toByteArray(StandardCharsets.UTF_8),
)
@@ -1,6 +1,8 @@
package com.osglab.account.features.admin.stats.services
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.stats.models.AdminCreditFlowPointDto
import com.osglab.account.features.admin.stats.models.AdminReferralRankDto
import com.osglab.account.features.admin.stats.models.AdminRegistrationPointDto
import com.osglab.account.features.admin.stats.models.AdminStatsDto
import com.osglab.account.features.admin.stats.models.AdminStatsPeriodDto
@@ -10,6 +12,12 @@ import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
enum class AdminReferralSort {
INVITED,
QUALIFIED,
CREDITS_EARNED,
}
class AdminStatsService(
private val repository: AdminStatsRepository,
) {
@@ -17,6 +25,8 @@ class AdminStatsService(
from: Instant,
until: Instant,
referralRankLimit: Int = 20,
referralSort: AdminReferralSort? = null,
referralOrder: AdminSortOrder = AdminSortOrder.DESC,
): AdminStatsDto {
require(from < until) { "Statistics range must be non-empty" }
require(referralRankLimit in 1..100) { "Referral rank limit must be between 1 and 100" }
@@ -40,12 +50,30 @@ class AdminStatsService(
)
},
referralFunnel = snapshot.referralFunnel,
referralRanking = snapshot.referralRanking.take(referralRankLimit),
referralRanking = snapshot.referralRanking
.sortedForReferralRanking(referralSort, referralOrder)
.take(referralRankLimit),
usage = snapshot.usage,
)
}
}
private fun List<AdminReferralRankDto>.sortedForReferralRanking(
sort: AdminReferralSort?,
order: AdminSortOrder,
): List<AdminReferralRankDto> {
if (sort == null) return this
val direction = if (order == AdminSortOrder.ASC) 1 else -1
return sortedWith { left, right ->
val primary = when (sort) {
AdminReferralSort.INVITED -> left.invitedUsers.compareTo(right.invitedUsers)
AdminReferralSort.QUALIFIED -> left.rewardedUsers.compareTo(right.rewardedUsers)
AdminReferralSort.CREDITS_EARNED -> left.earnedCredits.compareTo(right.earnedCredits)
} * direction
if (primary != 0) primary else left.userId.compareTo(right.userId) * direction
}
}
private fun utcDates(range: AdminStatsRange): List<LocalDate> {
val dates = mutableListOf<LocalDate>()
var date = range.from.atZone(ZoneOffset.UTC).toLocalDate()
@@ -1,6 +1,8 @@
package com.osglab.account.features.admin.users.repositories
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.models.AdminTimeFilter
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
@@ -14,11 +16,14 @@ import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.greater
import org.jetbrains.exposed.v1.core.greaterEq
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.less
import org.jetbrains.exposed.v1.core.like
import org.jetbrains.exposed.v1.core.or
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.andWhere
import org.jetbrains.exposed.v1.jdbc.selectAll
import java.time.Instant
import java.util.UUID
@@ -33,10 +38,54 @@ data class AdminUserLedgerCursor(
val ledgerEntryId: UUID,
)
interface AdminUsersRepository {
suspend fun list(limit: Int, cursor: AdminUserCursor?): List<AdminUserSummaryDto>
enum class AdminUserStatus {
ACTIVE,
SUSPENDED,
}
suspend fun findByIdSuffix(suffix: String, limit: Int): List<AdminUserSummaryDto>
data class AdminUserListQuery(
val time: AdminTimeFilter = AdminTimeFilter(),
val status: AdminUserStatus? = null,
val order: AdminSortOrder = AdminSortOrder.DESC,
)
enum class AdminLedgerType(
internal val entryTypes: Set<LedgerEntryType>,
) {
RESERVE(setOf(LedgerEntryType.USAGE_RESERVE)),
SETTLE(setOf(LedgerEntryType.USAGE_SETTLE)),
REFUND(setOf(LedgerEntryType.USAGE_RELEASE, LedgerEntryType.USAGE_REFUND)),
GRANT(
setOf(
LedgerEntryType.SIGNUP_TRIAL,
LedgerEntryType.MANUAL_GRANT,
LedgerEntryType.REFERRAL_INVITER,
LedgerEntryType.REFERRAL_INVITEE,
LedgerEntryType.STOREKIT_PURCHASE,
LedgerEntryType.SUBSCRIPTION_GRANT,
),
),
ADJUSTMENT(emptySet()),
}
data class AdminLedgerQuery(
val time: AdminTimeFilter = AdminTimeFilter(),
val type: AdminLedgerType? = null,
val order: AdminSortOrder = AdminSortOrder.DESC,
)
interface AdminUsersRepository {
suspend fun list(
limit: Int,
cursor: AdminUserCursor?,
query: AdminUserListQuery,
): List<AdminUserSummaryDto>
suspend fun findByIdSuffix(
suffix: String,
limit: Int,
query: AdminUserListQuery,
): List<AdminUserSummaryDto>
suspend fun exists(userId: UUID): Boolean
@@ -46,11 +95,13 @@ interface AdminUsersRepository {
userId: UUID,
limit: Int,
cursor: AdminUserLedgerCursor?,
query: AdminLedgerQuery,
): List<AdminUserLedgerEntryDto>
suspend fun listLatestLedger(
limit: Int,
cursor: AdminUserLedgerCursor?,
query: AdminLedgerQuery,
): List<AdminUserLedgerEntryDto>
}
@@ -60,21 +111,43 @@ class ExposedAdminUsersRepository(
override suspend fun list(
limit: Int,
cursor: AdminUserCursor?,
query: AdminUserListQuery,
): List<AdminUserSummaryDto> = databaseFactory.query {
val query = AdminUsersAccountsTable.selectAll()
if (cursor != null) {
query.where {
(AdminUsersAccountsTable.createdAt less cursor.createdAt) or
(
(AdminUsersAccountsTable.createdAt eq cursor.createdAt) and
(AdminUsersAccountsTable.id less cursor.userId.toString())
)
val statement = AdminUsersAccountsTable.selectAll()
query.time.from?.let { from ->
statement.andWhere { AdminUsersAccountsTable.createdAt greaterEq from }
}
query.time.until?.let { until ->
statement.andWhere { AdminUsersAccountsTable.createdAt less until }
}
query.status?.let { status ->
statement.andWhere {
AdminUsersAccountsTable.antiAbuseRestricted eq
(status == AdminUserStatus.SUSPENDED)
}
}
val accountRows = query
if (cursor != null) {
statement.andWhere {
if (query.order == AdminSortOrder.ASC) {
(AdminUsersAccountsTable.createdAt greater cursor.createdAt) or
(
(AdminUsersAccountsTable.createdAt eq cursor.createdAt) and
(AdminUsersAccountsTable.id greater cursor.userId.toString())
)
} else {
(AdminUsersAccountsTable.createdAt less cursor.createdAt) or
(
(AdminUsersAccountsTable.createdAt eq cursor.createdAt) and
(AdminUsersAccountsTable.id less cursor.userId.toString())
)
}
}
}
val sortOrder = query.order.toExposedSortOrder()
val accountRows = statement
.orderBy(
AdminUsersAccountsTable.createdAt to SortOrder.DESC,
AdminUsersAccountsTable.id to SortOrder.DESC,
AdminUsersAccountsTable.createdAt to sortOrder,
AdminUsersAccountsTable.id to sortOrder,
)
.limit(limit)
.toList()
@@ -85,12 +158,27 @@ class ExposedAdminUsersRepository(
override suspend fun findByIdSuffix(
suffix: String,
limit: Int,
query: AdminUserListQuery,
): List<AdminUserSummaryDto> = databaseFactory.query {
val accountRows = AdminUsersAccountsTable.selectAll()
val statement = AdminUsersAccountsTable.selectAll()
.where { AdminUsersAccountsTable.id like "%$suffix" }
query.time.from?.let { from ->
statement.andWhere { AdminUsersAccountsTable.createdAt greaterEq from }
}
query.time.until?.let { until ->
statement.andWhere { AdminUsersAccountsTable.createdAt less until }
}
query.status?.let { status ->
statement.andWhere {
AdminUsersAccountsTable.antiAbuseRestricted eq
(status == AdminUserStatus.SUSPENDED)
}
}
val sortOrder = query.order.toExposedSortOrder()
val accountRows = statement
.orderBy(
AdminUsersAccountsTable.createdAt to SortOrder.DESC,
AdminUsersAccountsTable.id to SortOrder.DESC,
AdminUsersAccountsTable.createdAt to sortOrder,
AdminUsersAccountsTable.id to sortOrder,
)
.limit(limit)
.toList()
@@ -155,25 +243,41 @@ class ExposedAdminUsersRepository(
userId: UUID,
limit: Int,
cursor: AdminUserLedgerCursor?,
query: AdminLedgerQuery,
): List<AdminUserLedgerEntryDto> = databaseFactory.query {
val query = AdminUsersCreditLedgerTable.selectAll()
if (cursor == null) {
query.where { AdminUsersCreditLedgerTable.userId eq userId.toString() }
} else {
query.where {
(AdminUsersCreditLedgerTable.userId eq userId.toString()) and
(
(AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or
(
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
)
)
if (query.type == AdminLedgerType.ADJUSTMENT) return@query emptyList()
val statement = AdminUsersCreditLedgerTable.selectAll()
.where { AdminUsersCreditLedgerTable.userId eq userId.toString() }
query.time.from?.let { from ->
statement.andWhere { AdminUsersCreditLedgerTable.createdAt greaterEq from }
}
query.time.until?.let { until ->
statement.andWhere { AdminUsersCreditLedgerTable.createdAt less until }
}
query.type?.let { type ->
statement.andWhere { AdminUsersCreditLedgerTable.entryType inList type.entryTypes }
}
if (cursor != null) {
statement.andWhere {
if (query.order == AdminSortOrder.ASC) {
(AdminUsersCreditLedgerTable.createdAt greater cursor.createdAt) or
(
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
(AdminUsersCreditLedgerTable.id greater cursor.ledgerEntryId.toString())
)
} else {
(AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or
(
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
)
}
}
}
val ledger = query.orderBy(
AdminUsersCreditLedgerTable.createdAt to SortOrder.DESC,
AdminUsersCreditLedgerTable.id to SortOrder.DESC,
val sortOrder = query.order.toExposedSortOrder()
val ledger = statement.orderBy(
AdminUsersCreditLedgerTable.createdAt to sortOrder,
AdminUsersCreditLedgerTable.id to sortOrder,
)
.limit(limit)
.map(ResultRow::toUserLedgerRow)
@@ -184,20 +288,40 @@ class ExposedAdminUsersRepository(
override suspend fun listLatestLedger(
limit: Int,
cursor: AdminUserLedgerCursor?,
query: AdminLedgerQuery,
): List<AdminUserLedgerEntryDto> = databaseFactory.query {
val query = AdminUsersCreditLedgerTable.selectAll()
if (query.type == AdminLedgerType.ADJUSTMENT) return@query emptyList()
val statement = AdminUsersCreditLedgerTable.selectAll()
query.time.from?.let { from ->
statement.andWhere { AdminUsersCreditLedgerTable.createdAt greaterEq from }
}
query.time.until?.let { until ->
statement.andWhere { AdminUsersCreditLedgerTable.createdAt less until }
}
query.type?.let { type ->
statement.andWhere { AdminUsersCreditLedgerTable.entryType inList type.entryTypes }
}
if (cursor != null) {
query.where {
(AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or
(
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
)
statement.andWhere {
if (query.order == AdminSortOrder.ASC) {
(AdminUsersCreditLedgerTable.createdAt greater cursor.createdAt) or
(
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
(AdminUsersCreditLedgerTable.id greater cursor.ledgerEntryId.toString())
)
} else {
(AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or
(
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
)
}
}
}
val ledger = query.orderBy(
AdminUsersCreditLedgerTable.createdAt to SortOrder.DESC,
AdminUsersCreditLedgerTable.id to SortOrder.DESC,
val sortOrder = query.order.toExposedSortOrder()
val ledger = statement.orderBy(
AdminUsersCreditLedgerTable.createdAt to sortOrder,
AdminUsersCreditLedgerTable.id to sortOrder,
)
.limit(limit)
.map(ResultRow::toUserLedgerRow)
@@ -413,3 +537,6 @@ private fun ResultRow.toUserReferralBindingRow() = UserReferralBindingRow(
private inline fun <T> Iterable<T>.exactSumOf(value: (T) -> Long): Long =
fold(0L) { total, item -> Math.addExact(total, value(item)) }
private fun AdminSortOrder.toExposedSortOrder(): SortOrder =
if (this == AdminSortOrder.ASC) SortOrder.ASC else SortOrder.DESC
@@ -1,11 +1,15 @@
package com.osglab.account.features.admin.users.services
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerPageDto
import com.osglab.account.features.admin.users.models.AdminUserPageDto
import com.osglab.account.features.admin.users.repositories.AdminUserCursor
import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery
import com.osglab.account.features.admin.users.repositories.AdminUserLedgerCursor
import com.osglab.account.features.admin.users.repositories.AdminUserListQuery
import com.osglab.account.features.admin.users.repositories.AdminUserStatus
import com.osglab.account.features.admin.users.repositories.AdminUsersRepository
import java.nio.charset.StandardCharsets
import java.time.Instant
@@ -17,18 +21,26 @@ class AdminUserNotFoundException : RuntimeException("Admin user view does not ex
class AdminUsersService(
private val repository: AdminUsersRepository,
) {
suspend fun searchByInternalId(query: String): AdminUserPageDto {
suspend fun searchByInternalId(
query: String,
listQuery: AdminUserListQuery = AdminUserListQuery(),
): AdminUserPageDto {
val normalized = query.trim()
val userId = runCatching { UUID.fromString(normalized) }.getOrNull()
if (userId != null) {
val user = repository.findDetail(userId, ledgerLimit = 1)?.summary
?.takeIf { it.matches(listQuery) }
return AdminUserPageDto(user?.let(::listOf) ?: emptyList(), null)
}
if (!SHORT_INTERNAL_ID.matches(normalized)) {
return AdminUserPageDto(emptyList(), null)
}
return AdminUserPageDto(
items = repository.findByIdSuffix(normalized.lowercase(), limit = MAX_SHORT_ID_MATCHES),
items = repository.findByIdSuffix(
normalized.lowercase(),
limit = MAX_SHORT_ID_MATCHES,
query = listQuery,
),
nextCursor = null,
)
}
@@ -36,10 +48,11 @@ class AdminUsersService(
suspend fun list(
limit: Int = 50,
cursor: String? = null,
query: AdminUserListQuery = AdminUserListQuery(),
): AdminUserPageDto {
require(limit in 1..100) { "User page limit must be between 1 and 100" }
val decodedCursor = cursor?.let(AdminUserCursorCodec::decode)
val results = repository.list(limit + 1, decodedCursor)
val decodedCursor = cursor?.let { AdminUserCursorCodec.decode(it, query.order) }
val results = repository.list(limit + 1, decodedCursor, query)
val hasMore = results.size > limit
val items = results.take(limit)
val nextCursor = if (hasMore) {
@@ -49,6 +62,7 @@ class AdminUsersService(
createdAt = Instant.parse(last.createdAt),
userId = UUID.fromString(last.id),
),
query.order,
)
} else {
null
@@ -68,26 +82,31 @@ class AdminUsersService(
userId: UUID,
limit: Int = 50,
cursor: String? = null,
query: AdminLedgerQuery = AdminLedgerQuery(),
): AdminUserLedgerPageDto {
return ledgerPage(limit, cursor) { pageSize, decodedCursor ->
return ledgerPage(limit, cursor, query) { pageSize, decodedCursor ->
if (!repository.exists(userId)) throw AdminUserNotFoundException()
repository.listLedger(userId, pageSize, decodedCursor)
repository.listLedger(userId, pageSize, decodedCursor, query)
}
}
suspend fun latestLedger(
limit: Int = 100,
cursor: String? = null,
query: AdminLedgerQuery = AdminLedgerQuery(),
): AdminUserLedgerPageDto =
ledgerPage(limit, cursor, repository::listLatestLedger)
ledgerPage(limit, cursor, query) { pageSize, decodedCursor ->
repository.listLatestLedger(pageSize, decodedCursor, query)
}
private suspend fun ledgerPage(
limit: Int,
cursor: String?,
query: AdminLedgerQuery,
load: suspend (Int, AdminUserLedgerCursor?) -> List<AdminUserLedgerEntryDto>,
): AdminUserLedgerPageDto {
require(limit in 1..100) { "Ledger page limit must be between 1 and 100" }
val decodedCursor = cursor?.let(AdminUserLedgerCursorCodec::decode)
val decodedCursor = cursor?.let { AdminUserLedgerCursorCodec.decode(it, query.order) }
val results = load(limit + 1, decodedCursor)
val hasMore = results.size > limit
val items = results.take(limit)
@@ -98,6 +117,7 @@ class AdminUsersService(
createdAt = Instant.parse(last.createdAt),
ledgerEntryId = UUID.fromString(last.id),
),
query.order,
)
} else {
null
@@ -109,14 +129,27 @@ class AdminUsersService(
private val SHORT_INTERNAL_ID = Regex("^[A-Fa-f0-9]{8}$")
private const val MAX_SHORT_ID_MATCHES = 100
private fun com.osglab.account.features.admin.users.models.AdminUserSummaryDto.matches(
query: AdminUserListQuery,
): Boolean {
val createdAt = Instant.parse(createdAt)
return query.time.from?.let { createdAt >= it } != false &&
query.time.until?.let { createdAt < it } != false &&
when (query.status) {
AdminUserStatus.ACTIVE -> !antiAbuseRestricted
AdminUserStatus.SUSPENDED -> antiAbuseRestricted
null -> true
}
}
internal object AdminUserCursorCodec {
fun encode(cursor: AdminUserCursor): String {
val value = "${cursor.createdAt}|${cursor.userId}"
fun encode(cursor: AdminUserCursor, order: AdminSortOrder): String {
val value = "v1|${order.name}|${cursor.createdAt}|${cursor.userId}"
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(value.toByteArray(StandardCharsets.UTF_8))
}
fun decode(value: String): AdminUserCursor {
fun decode(value: String, expectedOrder: AdminSortOrder): AdminUserCursor {
require(value.length in 1..256) { "User cursor is invalid" }
return try {
val decoded = String(
@@ -124,10 +157,12 @@ internal object AdminUserCursorCodec {
StandardCharsets.UTF_8,
)
val parts = decoded.split('|')
require(parts.size == 2)
require(parts.size == 4)
require(parts[0] == "v1")
require(parts[1] == expectedOrder.name)
AdminUserCursor(
createdAt = Instant.parse(parts[0]),
userId = UUID.fromString(parts[1]),
createdAt = Instant.parse(parts[2]),
userId = UUID.fromString(parts[3]),
)
} catch (failure: IllegalArgumentException) {
throw IllegalArgumentException("User cursor is invalid", failure)
@@ -138,13 +173,13 @@ internal object AdminUserCursorCodec {
internal object AdminUserLedgerCursorCodec {
private const val INVALID_CURSOR_MESSAGE = "User ledger cursor is invalid"
fun encode(cursor: AdminUserLedgerCursor): String {
val value = "${cursor.createdAt}|${cursor.ledgerEntryId}"
fun encode(cursor: AdminUserLedgerCursor, order: AdminSortOrder): String {
val value = "v1|${order.name}|${cursor.createdAt}|${cursor.ledgerEntryId}"
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(value.toByteArray(StandardCharsets.UTF_8))
}
fun decode(value: String): AdminUserLedgerCursor {
fun decode(value: String, expectedOrder: AdminSortOrder): AdminUserLedgerCursor {
require(value.length in 1..256) { INVALID_CURSOR_MESSAGE }
return try {
val decoded = String(
@@ -152,10 +187,12 @@ internal object AdminUserLedgerCursorCodec {
StandardCharsets.UTF_8,
)
val parts = decoded.split('|')
require(parts.size == 2)
require(parts.size == 4)
require(parts[0] == "v1")
require(parts[1] == expectedOrder.name)
AdminUserLedgerCursor(
createdAt = Instant.parse(parts[0]),
ledgerEntryId = UUID.fromString(parts[1]),
createdAt = Instant.parse(parts[2]),
ledgerEntryId = UUID.fromString(parts[3]),
)
} catch (failure: IllegalArgumentException) {
throw IllegalArgumentException(INVALID_CURSOR_MESSAGE, failure)
@@ -0,0 +1,26 @@
CREATE INDEX idx_accounts_restricted_created_id
ON accounts (anti_abuse_restricted, created_at, id);
CREATE INDEX idx_credit_ledger_type_created_id
ON credit_ledger (entry_type, created_at, id);
CREATE INDEX idx_credit_ledger_user_type_created_id
ON credit_ledger (user_id, entry_type, created_at, id);
CREATE INDEX idx_admin_audit_action_outcome_occurred_id
ON admin_audit_log (action, outcome, occurred_at, id);
CREATE INDEX idx_admin_audit_outcome_occurred_id
ON admin_audit_log (outcome, occurred_at, id);
CREATE INDEX idx_admin_operators_created_id
ON admin_operators (created_at, id);
CREATE INDEX idx_admin_operators_role_status_created_id
ON admin_operators (role, disabled_at, created_at, id);
CREATE INDEX idx_admin_operators_locked_created_id
ON admin_operators (locked_until, created_at, id);
CREATE INDEX idx_admin_operators_last_login_id
ON admin_operators (last_login_at, id);
@@ -1,14 +1,18 @@
package com.osglab.account.features.admin
import com.osglab.account.features.admin.models.AdminAuditCursor
import com.osglab.account.features.admin.models.AdminAuditQuery
import com.osglab.account.features.admin.models.AdminAuditRecord
import com.osglab.account.features.admin.models.AdminLockState
import com.osglab.account.features.admin.models.AdminOperatorAuthRecord
import com.osglab.account.features.admin.models.AdminOperatorCursor
import com.osglab.account.features.admin.models.AdminOperatorQuery
import com.osglab.account.features.admin.models.AdminOperatorSort
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
import com.osglab.account.features.admin.models.AdminOperatorRecord
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminSessionRecord
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.models.NewAdminSession
@@ -24,6 +28,7 @@ internal class InMemoryAdminRepository(
var passwordHash: String = "valid-password-hash",
var encryptedTotpSecret: String,
var role: AdminRole = AdminRole.SUPER_ADMIN,
var lastLoginAt: Instant? = null,
) : AdminRepository {
private val mutex = Mutex()
var lockState = AdminLockState(0, null)
@@ -66,19 +71,22 @@ internal class InMemoryAdminRepository(
override suspend fun listOperatorsPage(
limit: Int,
before: AdminOperatorCursor?,
query: AdminOperatorQuery,
): List<AdminOperatorRecord> = mutex.withLock {
require(limit in 1..101)
(listOf(baseOperatorRecord()) + additionalOperators.values.map(MutableOperator::toRecord))
.asSequence()
.filter {
before == null ||
it.createdAt.isAfter(before.createdAt) ||
(it.createdAt == before.createdAt && it.id.toString() > before.id.toString())
query.time.from?.let { from -> it.createdAt >= from } != false &&
query.time.until?.let { until -> it.createdAt < until } != false &&
query.role?.let { role -> it.role == role } != false &&
query.enabled?.let { enabled -> (it.disabledAt == null) == enabled } != false &&
query.locked?.let { locked ->
(it.lockState.lockedUntil?.isAfter(query.now) == true) == locked
} != false
}
.sortedWith(
compareBy<AdminOperatorRecord> { it.createdAt }
.thenBy { it.id.toString() },
)
.filter { before == null || operatorAfter(it, before, query) }
.sortedWith(operatorComparator(query))
.take(limit)
.toList()
}
@@ -307,20 +315,40 @@ internal class InMemoryAdminRepository(
override suspend fun listAudit(
limit: Int,
before: AdminAuditCursor?,
query: AdminAuditQuery,
): List<AdminAuditRecord> =
mutex.withLock {
audits.asSequence()
.filter {
query.time.from?.let { from -> it.occurredAt >= from } != false &&
query.time.until?.let { until -> it.occurredAt < until } != false &&
query.action?.let { action -> it.action == action } != false &&
query.outcome?.let { outcome -> it.outcome == outcome } != false
}
.filter {
before == null ||
it.occurredAt.isBefore(before.occurredAt) ||
(
it.occurredAt == before.occurredAt &&
it.id.toString() < before.id.toString()
)
if (query.order == AdminSortOrder.ASC) {
it.occurredAt > before.occurredAt ||
(
it.occurredAt == before.occurredAt &&
it.id.toString() > before.id.toString()
)
} else {
it.occurredAt < before.occurredAt ||
(
it.occurredAt == before.occurredAt &&
it.id.toString() < before.id.toString()
)
}
}
.sortedWith(
compareByDescending<NewAdminAuditEvent> { it.occurredAt }
.thenByDescending { it.id.toString() },
if (query.order == AdminSortOrder.ASC) {
compareBy<NewAdminAuditEvent> { it.occurredAt }
.thenBy { it.id.toString() }
} else {
compareByDescending<NewAdminAuditEvent> { it.occurredAt }
.thenByDescending { it.id.toString() }
},
)
.take(limit)
.map {
@@ -344,6 +372,27 @@ internal class InMemoryAdminRepository(
}
}
suspend fun configureOperatorListState(
targetId: UUID,
disabledAt: Instant? = null,
lockState: AdminLockState = AdminLockState(0, null),
lastLoginAt: Instant? = null,
) {
mutex.withLock {
if (targetId == operatorId) {
this.disabledAt = disabledAt
this.lockState = lockState
this.lastLoginAt = lastLoginAt
} else {
additionalOperators.getValue(targetId).apply {
this.disabledAt = disabledAt
this.lockState = lockState
this.lastLoginAt = lastLoginAt
}
}
}
}
private fun authRecord() = AdminOperatorAuthRecord(
id = operatorId,
normalizedUsername = username,
@@ -360,7 +409,7 @@ internal class InMemoryAdminRepository(
role = role,
lockState = lockState,
disabledAt = disabledAt,
lastLoginAt = null,
lastLoginAt = lastLoginAt,
createdAt = Instant.EPOCH,
updatedAt = Instant.EPOCH,
)
@@ -391,6 +440,7 @@ internal class InMemoryAdminRepository(
var lockState: AdminLockState = AdminLockState(0, null),
var lastTotpCounter: Long? = null,
var disabledAt: Instant? = null,
var lastLoginAt: Instant? = null,
var updatedAt: Instant = operator.createdAt,
) {
fun toAuthRecord() = AdminOperatorAuthRecord(
@@ -409,7 +459,7 @@ internal class InMemoryAdminRepository(
role = operator.role,
lockState = lockState,
disabledAt = disabledAt,
lastLoginAt = null,
lastLoginAt = lastLoginAt,
createdAt = operator.createdAt,
updatedAt = updatedAt,
)
@@ -424,3 +474,64 @@ private fun NewAdminAuditEvent.forResult(result: AdminOperatorMutationResult): N
com.osglab.account.features.admin.models.AdminAuditOutcome.DENIED
},
)
private fun operatorComparator(query: AdminOperatorQuery): Comparator<AdminOperatorRecord> =
Comparator { left, right ->
val primary = when (query.sort) {
AdminOperatorSort.CREATED_AT ->
orderedComparison(left.createdAt.compareTo(right.createdAt), query.order)
AdminOperatorSort.USERNAME ->
orderedComparison(left.normalizedUsername.compareTo(right.normalizedUsername), query.order)
AdminOperatorSort.LAST_LOGIN_AT -> compareNullableLast(
left.lastLoginAt,
right.lastLoginAt,
query.order,
)
}
if (primary != 0) {
primary
} else {
orderedComparison(left.id.toString().compareTo(right.id.toString()), query.order)
}
}
private fun operatorAfter(
record: AdminOperatorRecord,
cursor: AdminOperatorCursor,
query: AdminOperatorQuery,
): Boolean {
val primary = when (query.sort) {
AdminOperatorSort.CREATED_AT -> orderedComparison(
record.createdAt.compareTo(Instant.parse(requireNotNull(cursor.value))),
query.order,
)
AdminOperatorSort.USERNAME -> orderedComparison(
record.normalizedUsername.compareTo(requireNotNull(cursor.value)),
query.order,
)
AdminOperatorSort.LAST_LOGIN_AT -> compareNullableLast(
record.lastLoginAt,
cursor.value?.let(Instant::parse),
query.order,
)
}
return primary > 0 ||
(
primary == 0 &&
orderedComparison(record.id.toString().compareTo(cursor.id.toString()), query.order) > 0
)
}
private fun <T : Comparable<T>> compareNullableLast(
left: T?,
right: T?,
order: AdminSortOrder,
): Int = when {
left == null && right == null -> 0
left == null -> 1
right == null -> -1
else -> orderedComparison(left.compareTo(right), order)
}
private fun orderedComparison(value: Int, order: AdminSortOrder): Int =
if (order == AdminSortOrder.ASC) value else -value
@@ -4,12 +4,18 @@ import com.osglab.account.config.DatabaseConfig
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminAuditQuery
import com.osglab.account.features.admin.models.AdminOperatorQuery
import com.osglab.account.features.admin.models.AdminOperatorSort
import com.osglab.account.features.admin.models.AdminOperatorMutationResult
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.models.AdminTimeFilter
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.models.NewAdminSession
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.async
@@ -175,6 +181,85 @@ class AdminOperatorRepositoryIntegrationTest : FunSpec({
(repository.findActiveSessionByTokenHash(activeToken, now) != null) shouldBe true
}
}
test("MySQL list queries preserve filters keyset order and null-last semantics") {
withAdminRepositories { repository, _ ->
val from = Instant.parse("2026-08-17T00:00:00Z")
val until = from.plusSeconds(120)
val neverLoggedIn = UUID.fromString("10000000-0000-0000-0000-000000000001")
val earlier = UUID.fromString("20000000-0000-0000-0000-000000000002")
val later = UUID.fromString("30000000-0000-0000-0000-000000000003")
listOf(neverLoggedIn, earlier, later).forEachIndexed { index, id ->
repository.createOperatorIfAbsent(
newOperator(id, "query-operator-$index", AdminRole.SUPPORT, from),
)
}
listOf(earlier to from.plusSeconds(10), later to from.plusSeconds(20)).forEach {
(operatorId, loginAt) ->
repository.createSessionIfTotpCounterFresh(
session = NewAdminSession(
id = UUID.randomUUID(),
operatorId = operatorId,
tokenHash = operatorId.toString().replace("-", "").padEnd(64, '0'),
csrfTokenHash = operatorId.toString().replace("-", "").padEnd(64, 'f'),
createdAt = loginAt,
expiresAt = loginAt.plusSeconds(60),
),
totpCounter = 1,
now = loginAt,
auditEvent = audit(operatorId, AdminAuditAction.LOGIN_SUCCEEDED, operatorId, loginAt),
)
}
val operators = repository.listOperatorsPage(
limit = 10,
query = AdminOperatorQuery(
time = AdminTimeFilter(from, until),
role = AdminRole.SUPPORT,
sort = AdminOperatorSort.LAST_LOGIN_AT,
order = AdminSortOrder.DESC,
now = until,
),
)
operators.map { it.id } shouldContainExactly listOf(later, earlier, neverLoggedIn)
val lowerAuditId = UUID.fromString("40000000-0000-0000-0000-000000000004")
val higherAuditId = UUID.fromString("50000000-0000-0000-0000-000000000005")
listOf(
NewAdminAuditEvent(
id = higherAuditId,
actorOperatorId = null,
action = AdminAuditAction.LOGIN_FAILED,
outcome = AdminAuditOutcome.DENIED,
occurredAt = from,
),
NewAdminAuditEvent(
id = lowerAuditId,
actorOperatorId = null,
action = AdminAuditAction.LOGIN_FAILED,
outcome = AdminAuditOutcome.DENIED,
occurredAt = from,
),
NewAdminAuditEvent(
actorOperatorId = null,
action = AdminAuditAction.LOGIN_FAILED,
outcome = AdminAuditOutcome.DENIED,
occurredAt = until,
),
).forEach { repository.appendAudit(it) }
val audits = repository.listAudit(
limit = 10,
query = AdminAuditQuery(
time = AdminTimeFilter(from, until),
action = AdminAuditAction.LOGIN_FAILED,
outcome = AdminAuditOutcome.DENIED,
order = AdminSortOrder.ASC,
),
)
audits.map { it.id } shouldContainExactly listOf(lowerAuditId, higherAuditId)
}
}
})
private suspend fun withAdminRepositories(
@@ -188,7 +188,7 @@ class AdminRoutesTest {
fun `operator list maps non-super authorization to stable forbidden response`() = testApplication {
val sessionService = sessionFixture(AdminRole.SUPPORT)
val operatorService = mockk<AdminOperatorService>()
coEvery { operatorService.listPage(any(), any(), any()) } throws
coEvery { operatorService.listPage(any(), any(), any(), any()) } throws
AdminOperatorException(AdminOperatorErrorCode.INSUFFICIENT_PERMISSION)
application {
installAdminTestRoutes(
@@ -259,6 +259,34 @@ class AdminRoutesTest {
response.bodyAsText() shouldContain """"usageType":"hotword""""
}
@Test
fun `admin list routes reject non-whitelisted filters and invalid ranges`() = testApplication {
application {
installAdminTestRoutes(
sessionService = sessionFixture(AdminRole.SUPER_ADMIN),
)
}
val invalidPaths = listOf(
"/v1/admin/users?from=2026-08-20T00:00:00Z&until=2026-08-20T00:00:00Z",
"/v1/admin/credits/ledger?type=unknown",
"/v1/admin/operators?enabled=1",
"/v1/admin/audit?action=NOT_AN_ACTION",
"/v1/admin/referrals?range=30d&limit=101",
"/v1/admin/users?unexpected=value",
"/v1/admin/users?from=2026-08-20T08:00:00%2B08:00",
)
invalidPaths.forEach { path ->
val response = client.get(path) {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
}
assertEquals(HttpStatusCode.BadRequest, response.status, path)
response.bodyAsText() shouldContain """"code":"VALIDATION_ERROR""""
}
}
@Test
fun `operator creation maps normalized username conflict to 409`() = testApplication {
val sessionService = sessionFixture(AdminRole.SUPER_ADMIN)
@@ -1,10 +1,15 @@
package com.osglab.account.features.admin.services
import com.osglab.account.features.admin.InMemoryAdminRepository
import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminAuditQuery
import com.osglab.account.features.admin.models.AdminAuditRecord
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.models.AdminTimeFilter
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.admin.repositories.AdminRepository
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
@@ -66,6 +71,54 @@ class AdminAuditServiceTest : FunSpec({
service.list(support, null)
}.code shouldBe AdminOperatorErrorCode.INSUFFICIENT_PERMISSION
}
test("audit filters use half-open boundaries and ascending ID tie-break") {
val repository = InMemoryAdminRepository(encryptedTotpSecret = "encrypted")
val actor = superAdministrator().copy(operatorId = repository.operatorId)
val from = Instant.parse("2026-08-17T00:00:00Z")
val until = from.plusSeconds(60)
val lowerId = UUID.fromString("11111111-1111-4111-8111-111111111111")
val higherId = UUID.fromString("22222222-2222-4222-8222-222222222222")
listOf(
auditEvent(lowerId, from, AdminAuditAction.LOGIN_FAILED, AdminAuditOutcome.DENIED),
auditEvent(higherId, from, AdminAuditAction.LOGIN_FAILED, AdminAuditOutcome.DENIED),
auditEvent(UUID.randomUUID(), from.minusNanos(1), AdminAuditAction.LOGIN_FAILED, AdminAuditOutcome.DENIED),
auditEvent(UUID.randomUUID(), until, AdminAuditAction.LOGIN_FAILED, AdminAuditOutcome.DENIED),
auditEvent(UUID.randomUUID(), from.plusSeconds(1), AdminAuditAction.LOGIN_SUCCEEDED, AdminAuditOutcome.SUCCESS),
).forEach { repository.appendAudit(it) }
val page = AdminAuditService(repository).list(
actor = actor,
cursor = null,
query = AdminAuditQuery(
time = AdminTimeFilter(from, until),
action = AdminAuditAction.LOGIN_FAILED,
outcome = AdminAuditOutcome.DENIED,
order = AdminSortOrder.ASC,
),
)
page.items.map { it.record.id } shouldBe listOf(lowerId, higherId)
}
test("audit cursor rejects the opposite order") {
val repository = InMemoryAdminRepository(encryptedTotpSecret = "encrypted")
val actor = superAdministrator().copy(operatorId = repository.operatorId)
listOf(
auditEvent(UUID.randomUUID(), Instant.parse("2026-08-17T00:00:02Z")),
auditEvent(UUID.randomUUID(), Instant.parse("2026-08-17T00:00:01Z")),
).forEach { repository.appendAudit(it) }
val service = AdminAuditService(repository)
val first = service.list(actor, null, limit = 1)
shouldThrow<AdminAuditCursorException> {
service.list(
actor,
first.nextCursor,
query = AdminAuditQuery(order = AdminSortOrder.ASC),
)
}
}
})
private fun auditRecord(occurredAt: String) = AdminAuditRecord(
@@ -79,6 +132,19 @@ private fun auditRecord(occurredAt: String) = AdminAuditRecord(
occurredAt = Instant.parse(occurredAt),
)
private fun auditEvent(
id: UUID,
occurredAt: Instant,
action: AdminAuditAction = AdminAuditAction.LOGIN_SUCCEEDED,
outcome: AdminAuditOutcome = AdminAuditOutcome.SUCCESS,
) = NewAdminAuditEvent(
id = id,
actorOperatorId = null,
action = action,
outcome = outcome,
occurredAt = occurredAt,
)
private fun superAdministrator() = AdminPrincipal(
operatorId = UUID.randomUUID(),
sessionId = UUID.randomUUID(),
@@ -6,9 +6,13 @@ import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.AdminLockState
import com.osglab.account.features.admin.models.AdminOperatorRecord
import com.osglab.account.features.admin.models.AdminOperatorQuery
import com.osglab.account.features.admin.models.AdminOperatorSort
import com.osglab.account.features.admin.models.AdminPrincipal
import com.osglab.account.features.admin.models.AdminRole
import com.osglab.account.features.admin.models.AdminSessionRecord
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.models.AdminTimeFilter
import com.osglab.account.features.admin.models.NewAdminOperator
import com.osglab.account.features.admin.security.AdminPasswordHasher
import com.osglab.account.features.admin.security.AdminTotpProvisioning
@@ -216,6 +220,104 @@ class AdminOperatorServiceTest : FunSpec({
fixture.service.listPage(fixture.owner, cursor = "not-a-cursor")
}
}
test("operator filters apply half-open time role enabled and current lock state") {
val fixture = operatorFixture()
val matching = fixture.seedOperator("locked-support", AdminRole.SUPPORT)
val expired = fixture.seedOperator("expired-support", AdminRole.SUPPORT)
val disabled = fixture.seedOperator("disabled-support", AdminRole.SUPPORT)
fixture.repository.configureOperatorListState(
matching,
lockState = AdminLockState(2, fixture.clock.instant().plusSeconds(60)),
)
fixture.repository.configureOperatorListState(
expired,
lockState = AdminLockState(2, fixture.clock.instant()),
)
fixture.repository.configureOperatorListState(
disabled,
disabledAt = fixture.clock.instant(),
lockState = AdminLockState(2, fixture.clock.instant().plusSeconds(60)),
)
val page = fixture.service.listPage(
actor = fixture.owner,
cursor = null,
query = AdminOperatorQuery(
time = AdminTimeFilter(
fixture.clock.instant(),
fixture.clock.instant().plusNanos(1),
),
role = AdminRole.SUPPORT,
enabled = true,
locked = true,
now = fixture.clock.instant(),
),
)
page.items.map(AdminOperatorRecord::id) shouldBe listOf(matching)
}
test("last login sorting keeps null values last in both directions") {
val fixture = operatorFixture()
val earlier = fixture.seedOperator("earlier-login", AdminRole.SUPPORT)
val later = fixture.seedOperator("later-login", AdminRole.SUPPORT)
fixture.repository.configureOperatorListState(
earlier,
lastLoginAt = fixture.clock.instant().minusSeconds(60),
)
fixture.repository.configureOperatorListState(
later,
lastLoginAt = fixture.clock.instant(),
)
val ascending = fixture.service.listPage(
fixture.owner,
cursor = null,
query = AdminOperatorQuery(
sort = AdminOperatorSort.LAST_LOGIN_AT,
order = AdminSortOrder.ASC,
now = fixture.clock.instant(),
),
)
val descending = fixture.service.listPage(
fixture.owner,
cursor = null,
query = AdminOperatorQuery(
sort = AdminOperatorSort.LAST_LOGIN_AT,
order = AdminSortOrder.DESC,
now = fixture.clock.instant(),
),
)
ascending.items.map(AdminOperatorRecord::id) shouldBe
listOf(earlier, later, fixture.owner.operatorId)
descending.items.map(AdminOperatorRecord::id) shouldBe
listOf(later, earlier, fixture.owner.operatorId)
}
test("operator cursor rejects a different sort contract") {
val fixture = operatorFixture()
fixture.seedOperator("cursor-a", AdminRole.SUPPORT)
fixture.seedOperator("cursor-b", AdminRole.SUPPORT)
val first = fixture.service.listPage(
fixture.owner,
cursor = null,
limit = 1,
query = AdminOperatorQuery(now = fixture.clock.instant()),
)
shouldThrow<AdminOperatorCursorException> {
fixture.service.listPage(
fixture.owner,
cursor = first.nextCursor,
query = AdminOperatorQuery(
sort = AdminOperatorSort.USERNAME,
now = fixture.clock.instant(),
),
)
}
}
})
private data class OperatorFixture(
@@ -1,7 +1,9 @@
package com.osglab.account.features.admin.stats
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.stats.models.AdminOverviewDto
import com.osglab.account.features.admin.stats.models.AdminReferralFunnelDto
import com.osglab.account.features.admin.stats.models.AdminReferralRankDto
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.admin.stats.repositories.AdminStatsAggregates
import com.osglab.account.features.admin.stats.repositories.AdminStatsRange
@@ -11,6 +13,7 @@ import com.osglab.account.features.admin.stats.repositories.ReferralBindingAggre
import com.osglab.account.features.admin.stats.repositories.assembleAdminStats
import com.osglab.account.features.admin.stats.repositories.toExactLong
import com.osglab.account.features.admin.stats.services.AdminStatsService
import com.osglab.account.features.admin.stats.services.AdminReferralSort
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
@@ -159,4 +162,40 @@ class AdminStatsRepositoryTest : FunSpec({
BigDecimal("1.5").toExactLong()
}
}
test("referral ranking supports explicit metric order stable ID tie-break and limit") {
val snapshot = AdminStatsSnapshot(
overview = AdminOverviewDto(0, 0, 0, 0, 0, 0),
registrationsByDate = emptyMap(),
issuedCreditsByDate = emptyMap(),
consumedCreditsByDate = emptyMap(),
referralFunnel = AdminReferralFunnelDto(0, 0, 0, 0, 0),
referralRanking = listOf(
AdminReferralRankDto("user-c", 2, 1, 20),
AdminReferralRankDto("user-a", 3, 1, 20),
AdminReferralRankDto("user-b", 1, 2, 10),
),
usage = emptyList(),
)
val service = AdminStatsService(AdminStatsRepository { snapshot })
val descending = service.get(
from,
until,
referralRankLimit = 2,
referralSort = AdminReferralSort.CREDITS_EARNED,
referralOrder = AdminSortOrder.DESC,
)
val ascending = service.get(
from,
until,
referralSort = AdminReferralSort.QUALIFIED,
referralOrder = AdminSortOrder.ASC,
)
descending.referralRanking.map(AdminReferralRankDto::userId) shouldBe
listOf("user-c", "user-a")
ascending.referralRanking.map(AdminReferralRankDto::userId) shouldBe
listOf("user-a", "user-c", "user-b")
}
})
@@ -5,8 +5,14 @@ import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.models.AdminUserReferralDto
import com.osglab.account.features.admin.users.models.AdminUserSummaryDto
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.models.AdminTimeFilter
import com.osglab.account.features.admin.users.repositories.AdminLedgerType
import com.osglab.account.features.admin.users.repositories.AdminUserCursor
import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery
import com.osglab.account.features.admin.users.repositories.AdminUserLedgerCursor
import com.osglab.account.features.admin.users.repositories.AdminUserListQuery
import com.osglab.account.features.admin.users.repositories.AdminUserStatus
import com.osglab.account.features.admin.users.repositories.AdminUsersRepository
import com.osglab.account.features.admin.users.services.AdminUserNotFoundException
import com.osglab.account.features.admin.users.services.AdminUsersService
@@ -223,6 +229,112 @@ class AdminUsersServiceTest : FunSpec({
service.detail(UUID.randomUUID())
}
}
test("user filters use a UTC half-open range and ascending ID tie-break") {
val boundary = Instant.parse("2026-08-15T00:00:00Z")
val until = Instant.parse("2026-08-16T00:00:00Z")
val lowerId = UUID.fromString("11111111-1111-4111-8111-111111111111")
val higherId = UUID.fromString("22222222-2222-4222-8222-222222222222")
val service = AdminUsersService(
PagingUsersRepository(
listOf(
summary(higherId, boundary, restricted = true),
summary(lowerId, boundary, restricted = true),
summary(UUID.randomUUID(), boundary.minusNanos(1), restricted = true),
summary(UUID.randomUUID(), until, restricted = true),
summary(UUID.randomUUID(), boundary.plusSeconds(1), restricted = false),
),
),
)
val page = service.list(
query = AdminUserListQuery(
time = AdminTimeFilter(boundary, until),
status = AdminUserStatus.SUSPENDED,
order = AdminSortOrder.ASC,
),
)
page.items.map(AdminUserSummaryDto::id) shouldBe
listOf(lowerId.toString(), higherId.toString())
}
test("ledger filters type and half-open range in ascending stable order") {
val userId = UUID.randomUUID()
val from = Instant.parse("2026-08-15T00:00:00Z")
val until = from.plusSeconds(60)
val lowerId = UUID.fromString("11111111-1111-4111-8111-111111111111")
val higherId = UUID.fromString("22222222-2222-4222-8222-222222222222")
val entries = listOf(
ledgerEntry(higherId, from, userId),
ledgerEntry(lowerId, from, userId),
ledgerEntry(UUID.randomUUID(), from.minusNanos(1), userId),
ledgerEntry(UUID.randomUUID(), until, userId),
ledgerEntry(UUID.randomUUID(), from.plusSeconds(1), userId, type = "USAGE_SETTLE"),
)
val service = AdminUsersService(
PagingUsersRepository(emptyList(), ledger = mapOf(userId to entries)),
)
val page = service.ledger(
userId = userId,
query = AdminLedgerQuery(
time = AdminTimeFilter(from, until),
type = AdminLedgerType.GRANT,
order = AdminSortOrder.ASC,
),
)
page.items.map(AdminUserLedgerEntryDto::id) shouldBe
listOf(lowerId.toString(), higherId.toString())
}
test("versioned user cursor rejects the opposite sort direction") {
val service = AdminUsersService(
PagingUsersRepository(
listOf(
summary(UUID.randomUUID(), Instant.parse("2026-08-15T02:00:00Z")),
summary(UUID.randomUUID(), Instant.parse("2026-08-15T01:00:00Z")),
),
),
)
val first = service.list(limit = 1, query = AdminUserListQuery(order = AdminSortOrder.DESC))
shouldThrow<IllegalArgumentException> {
service.list(
cursor = first.nextCursor.shouldNotBeNull(),
query = AdminUserListQuery(order = AdminSortOrder.ASC),
)
}
}
test("versioned ledger cursor rejects the opposite sort direction") {
val userId = UUID.randomUUID()
val service = AdminUsersService(
PagingUsersRepository(
emptyList(),
ledger = mapOf(
userId to listOf(
ledgerEntry(UUID.randomUUID(), Instant.parse("2026-08-15T02:00:00Z"), userId),
ledgerEntry(UUID.randomUUID(), Instant.parse("2026-08-15T01:00:00Z"), userId),
),
),
),
)
val first = service.ledger(
userId,
limit = 1,
query = AdminLedgerQuery(order = AdminSortOrder.DESC),
)
shouldThrow<IllegalArgumentException> {
service.ledger(
userId,
cursor = first.nextCursor.shouldNotBeNull(),
query = AdminLedgerQuery(order = AdminSortOrder.ASC),
)
}
}
})
private class PagingUsersRepository(
@@ -233,15 +345,24 @@ private class PagingUsersRepository(
override suspend fun list(
limit: Int,
cursor: AdminUserCursor?,
query: AdminUserListQuery,
): List<AdminUserSummaryDto> =
users.filter {
cursor == null ||
Instant.parse(it.createdAt) < cursor.createdAt ||
(
Instant.parse(it.createdAt) == cursor.createdAt &&
UUID.fromString(it.id).toString() < cursor.userId.toString()
)
}.take(limit)
users.asSequence()
.filter { it.matches(query) }
.filter {
cursor == null || userAfter(it, cursor, query.order)
}
.sortedWith(
if (query.order == AdminSortOrder.ASC) {
compareBy<AdminUserSummaryDto> { Instant.parse(it.createdAt) }
.thenBy(AdminUserSummaryDto::id)
} else {
compareByDescending<AdminUserSummaryDto> { Instant.parse(it.createdAt) }
.thenByDescending(AdminUserSummaryDto::id)
},
)
.take(limit)
.toList()
override suspend fun findDetail(
userId: UUID,
@@ -251,8 +372,11 @@ private class PagingUsersRepository(
override suspend fun findByIdSuffix(
suffix: String,
limit: Int,
query: AdminUserListQuery,
): List<AdminUserSummaryDto> =
users.filter { it.id.endsWith(suffix, ignoreCase = true) }.take(limit)
users.filter {
it.id.endsWith(suffix, ignoreCase = true) && it.matches(query)
}.take(limit)
override suspend fun exists(userId: UUID): Boolean =
userId in details || userId in ledger || users.any { it.id == userId.toString() }
@@ -261,48 +385,108 @@ private class PagingUsersRepository(
userId: UUID,
limit: Int,
cursor: AdminUserLedgerCursor?,
query: AdminLedgerQuery,
): List<AdminUserLedgerEntryDto> =
ledger[userId].orEmpty()
.filter { it.matches(query) }
.filter {
val createdAt = Instant.parse(it.createdAt)
cursor == null ||
createdAt < cursor.createdAt ||
(
createdAt == cursor.createdAt &&
it.id < cursor.ledgerEntryId.toString()
)
cursor == null || ledgerAfter(it, cursor, query.order)
}
.sortedWith(
compareByDescending<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
.thenByDescending(AdminUserLedgerEntryDto::id),
ledgerComparator(query.order),
)
.take(limit)
override suspend fun listLatestLedger(
limit: Int,
cursor: AdminUserLedgerCursor?,
query: AdminLedgerQuery,
): List<AdminUserLedgerEntryDto> =
ledger.values.flatten()
.filter { it.matches(query) }
.filter {
val createdAt = Instant.parse(it.createdAt)
cursor == null ||
createdAt < cursor.createdAt ||
(
createdAt == cursor.createdAt &&
it.id < cursor.ledgerEntryId.toString()
)
cursor == null || ledgerAfter(it, cursor, query.order)
}
.sortedWith(
compareByDescending<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
.thenByDescending(AdminUserLedgerEntryDto::id),
ledgerComparator(query.order),
)
.take(limit)
}
private fun summary(id: UUID, createdAt: Instant) = AdminUserSummaryDto(
private fun AdminUserSummaryDto.matches(query: AdminUserListQuery): Boolean {
val instant = Instant.parse(createdAt)
return query.time.from?.let { instant >= it } != false &&
query.time.until?.let { instant < it } != false &&
when (query.status) {
AdminUserStatus.ACTIVE -> !antiAbuseRestricted
AdminUserStatus.SUSPENDED -> antiAbuseRestricted
null -> true
}
}
private fun userAfter(
item: AdminUserSummaryDto,
cursor: AdminUserCursor,
order: AdminSortOrder,
): Boolean {
val createdAt = Instant.parse(item.createdAt)
return if (order == AdminSortOrder.ASC) {
createdAt > cursor.createdAt ||
(createdAt == cursor.createdAt && item.id > cursor.userId.toString())
} else {
createdAt < cursor.createdAt ||
(createdAt == cursor.createdAt && item.id < cursor.userId.toString())
}
}
private fun AdminUserLedgerEntryDto.matches(query: AdminLedgerQuery): Boolean {
val instant = Instant.parse(createdAt)
val category = when (type) {
"USAGE_RESERVE" -> AdminLedgerType.RESERVE
"USAGE_SETTLE" -> AdminLedgerType.SETTLE
"USAGE_RELEASE", "USAGE_REFUND" -> AdminLedgerType.REFUND
"SIGNUP_TRIAL", "MANUAL_GRANT", "REFERRAL_INVITER", "REFERRAL_INVITEE",
"STOREKIT_PURCHASE", "SUBSCRIPTION_GRANT",
-> AdminLedgerType.GRANT
else -> AdminLedgerType.ADJUSTMENT
}
return query.time.from?.let { instant >= it } != false &&
query.time.until?.let { instant < it } != false &&
(query.type == null || query.type == category)
}
private fun ledgerAfter(
item: AdminUserLedgerEntryDto,
cursor: AdminUserLedgerCursor,
order: AdminSortOrder,
): Boolean {
val createdAt = Instant.parse(item.createdAt)
return if (order == AdminSortOrder.ASC) {
createdAt > cursor.createdAt ||
(createdAt == cursor.createdAt && item.id > cursor.ledgerEntryId.toString())
} else {
createdAt < cursor.createdAt ||
(createdAt == cursor.createdAt && item.id < cursor.ledgerEntryId.toString())
}
}
private fun ledgerComparator(order: AdminSortOrder): Comparator<AdminUserLedgerEntryDto> =
if (order == AdminSortOrder.ASC) {
compareBy<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
.thenBy(AdminUserLedgerEntryDto::id)
} else {
compareByDescending<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
.thenByDescending(AdminUserLedgerEntryDto::id)
}
private fun summary(
id: UUID,
createdAt: Instant,
restricted: Boolean = false,
) = AdminUserSummaryDto(
id = id.toString(),
createdAt = createdAt.toString(),
antiAbuseRestricted = false,
antiAbuseRestricted = restricted,
creditBalance = 0,
consumedCredits = 0,
manualGrantedCredits = 0,
@@ -316,10 +500,11 @@ private fun ledgerEntry(
id: UUID,
createdAt: Instant,
userId: UUID = UUID.fromString("11111111-1111-4111-8111-111111111111"),
type: String = "MANUAL_GRANT",
) = AdminUserLedgerEntryDto(
id = id.toString(),
userId = userId.toString(),
type = "MANUAL_GRANT",
type = type,
amountDelta = 10,
balanceAfter = 10,
referenceId = null,