From e8550cee42e69e15af5f81733e8e00476a72695a Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 4 Dec 2025 15:54:47 +0800 Subject: [PATCH] feat: enhance tenant management with pagination and sorting options - Updated the SuperAdminTenantManager component to support pagination and sorting of tenant data. - Introduced new parameters for fetching tenants, including page, limit, status, sortBy, and sortDir. - Enhanced the UI to allow users to filter tenants by status and sort by name or creation date. - Updated localization files to include new strings for tenant filtering and pagination. Signed-off-by: Innei --- .../contents/getting-started/quick-start.mdx | 4 +- apps/docs/contents/storage/providers/b2.mdx | 4 +- .../docs/contents/storage/providers/eagle.mdx | 4 +- .../contents/storage/providers/github.mdx | 4 +- .../docs/contents/storage/providers/index.mdx | 4 +- .../docs/contents/storage/providers/local.mdx | 4 +- apps/docs/contents/storage/providers/s3.mdx | 4 +- .../super-admin-tenants.controller.ts | 21 +++- .../platform/tenant/tenant.repository.ts | 48 +++++-- .../modules/platform/tenant/tenant.service.ts | 16 ++- .../src/modules/auth/hooks/useLogin.ts | 1 + .../dashboard/src/modules/super-admin/api.ts | 19 ++- .../components/SuperAdminTenantManager.tsx | 117 +++++++++++++++++- .../src/modules/super-admin/hooks.ts | 8 +- .../src/modules/super-admin/types.ts | 9 ++ .../src/pages/(onboarding)/root-login.tsx | 4 + be/packages/db/scripts/fix-migration-8.sql | 2 + locales/dashboard/en.json | 4 + locales/dashboard/zh-CN.json | 4 + 19 files changed, 251 insertions(+), 30 deletions(-) diff --git a/apps/docs/contents/getting-started/quick-start.mdx b/apps/docs/contents/getting-started/quick-start.mdx index 5cc3560d..a773f754 100644 --- a/apps/docs/contents/getting-started/quick-start.mdx +++ b/apps/docs/contents/getting-started/quick-start.mdx @@ -2,7 +2,7 @@ title: Quick Start description: Get your gallery running in about 5 minutes. createdAt: 2025-11-14T22:20:00+08:00 -lastModified: 2025-11-30T14:03:05+08:00 +lastModified: 2025-12-04T15:54:49+08:00 order: 2 --- @@ -112,3 +112,5 @@ Deploy to Vercel or any Node.js host. See [Vercel Deployment](/deployment/vercel + + diff --git a/apps/docs/contents/storage/providers/b2.mdx b/apps/docs/contents/storage/providers/b2.mdx index 778c847b..f5c44909 100644 --- a/apps/docs/contents/storage/providers/b2.mdx +++ b/apps/docs/contents/storage/providers/b2.mdx @@ -2,7 +2,7 @@ title: B2 (Backblaze B2) description: Configure Backblaze B2 storage for cost-effective cloud storage. createdAt: 2025-11-14T22:10:00+08:00 -lastModified: 2025-11-30T14:03:05+08:00 +lastModified: 2025-12-04T15:54:49+08:00 order: 33 --- @@ -95,3 +95,5 @@ Compare with AWS S3 to see which fits your usage pattern better. - B2 has generous rate limits, but very high concurrency may still hit limits - Reduce concurrency if needed + + diff --git a/apps/docs/contents/storage/providers/eagle.mdx b/apps/docs/contents/storage/providers/eagle.mdx index 71ccbf50..16af5636 100644 --- a/apps/docs/contents/storage/providers/eagle.mdx +++ b/apps/docs/contents/storage/providers/eagle.mdx @@ -2,7 +2,7 @@ title: Eagle Storage description: Publish directly from an Eagle 4 library with filtering support. createdAt: 2025-11-14T22:10:00+08:00 -lastModified: 2025-11-30T14:03:05+08:00 +lastModified: 2025-12-04T15:54:49+08:00 order: 36 --- @@ -166,3 +166,5 @@ This creates tags in the manifest based on folder structure, useful for organizi + + diff --git a/apps/docs/contents/storage/providers/github.mdx b/apps/docs/contents/storage/providers/github.mdx index 16372384..fa17068b 100644 --- a/apps/docs/contents/storage/providers/github.mdx +++ b/apps/docs/contents/storage/providers/github.mdx @@ -2,7 +2,7 @@ title: GitHub Storage description: Use a GitHub repository as photo storage for simple deployments. createdAt: 2025-11-14T22:10:00+08:00 -lastModified: 2025-11-30T14:03:05+08:00 +lastModified: 2025-12-04T15:54:49+08:00 order: 34 --- @@ -132,3 +132,5 @@ For private repositories: + + diff --git a/apps/docs/contents/storage/providers/index.mdx b/apps/docs/contents/storage/providers/index.mdx index d06faca6..089ebc53 100644 --- a/apps/docs/contents/storage/providers/index.mdx +++ b/apps/docs/contents/storage/providers/index.mdx @@ -2,7 +2,7 @@ title: Storage Providers description: Choose a storage provider for your photo collection. createdAt: 2025-11-14T22:40:00+08:00 -lastModified: 2025-11-30T14:03:05+08:00 +lastModified: 2025-12-04T15:54:49+08:00 order: 30 --- @@ -112,3 +112,5 @@ See each provider's documentation for specific configuration options. + + diff --git a/apps/docs/contents/storage/providers/local.mdx b/apps/docs/contents/storage/providers/local.mdx index 47880367..508275a1 100644 --- a/apps/docs/contents/storage/providers/local.mdx +++ b/apps/docs/contents/storage/providers/local.mdx @@ -2,7 +2,7 @@ title: Local Storage description: Use local file system paths for development and self-hosting. createdAt: 2025-11-14T22:10:00+08:00 -lastModified: 2025-11-30T14:03:05+08:00 +lastModified: 2025-12-04T15:54:49+08:00 order: 35 --- @@ -135,3 +135,5 @@ If you want to serve original photos: + + diff --git a/apps/docs/contents/storage/providers/s3.mdx b/apps/docs/contents/storage/providers/s3.mdx index f76a392c..e177b6dd 100644 --- a/apps/docs/contents/storage/providers/s3.mdx +++ b/apps/docs/contents/storage/providers/s3.mdx @@ -2,7 +2,7 @@ title: S3 / S3-Compatible description: Configure S3 or S3-compatible storage for your photo collection. createdAt: 2025-11-14T22:10:00+08:00 -lastModified: 2025-11-30T14:03:05+08:00 +lastModified: 2025-12-04T15:54:49+08:00 order: 32 --- @@ -122,3 +122,5 @@ This prevents processing temporary or system files. + + diff --git a/be/apps/core/src/modules/platform/super-admin/super-admin-tenants.controller.ts b/be/apps/core/src/modules/platform/super-admin/super-admin-tenants.controller.ts index 54e6226c..d0ad88af 100644 --- a/be/apps/core/src/modules/platform/super-admin/super-admin-tenants.controller.ts +++ b/be/apps/core/src/modules/platform/super-admin/super-admin-tenants.controller.ts @@ -41,12 +41,26 @@ export class SuperAdminTenantController { } @Get('/') - async listTenants() { - const [tenantAggregates, plans] = await Promise.all([ - this.tenantService.listTenants(), + async listTenants( + @Query('page') page = '1', + @Query('limit') limit = '20', + @Query('status') status?: string, + @Query('sortBy') sortBy?: 'createdAt' | 'name', + @Query('sortDir') sortDir?: 'asc' | 'desc', + ) { + const [tenantResult, plans] = await Promise.all([ + this.tenantService.listTenants({ + page: Number(page), + limit: Number(limit), + status: status as any, + sortBy, + sortDir, + }), Promise.resolve(this.billingPlanService.getPlanDefinitions()), ]) + const { items: tenantAggregates, total } = tenantResult + const tenantIds = tenantAggregates.map((aggregate) => aggregate.tenant.id) const usageTotalsMap = await this.billingUsageService.getUsageTotalsForTenants(tenantIds) @@ -56,6 +70,7 @@ export class SuperAdminTenantController { usageTotals: usageTotalsMap[aggregate.tenant.id] ?? [], })), plans, + total, } } diff --git a/be/apps/core/src/modules/platform/tenant/tenant.repository.ts b/be/apps/core/src/modules/platform/tenant/tenant.repository.ts index 23b40259..0718569c 100644 --- a/be/apps/core/src/modules/platform/tenant/tenant.repository.ts +++ b/be/apps/core/src/modules/platform/tenant/tenant.repository.ts @@ -1,12 +1,12 @@ import { generateId, tenants } from '@afilmory/db' -import { isTenantSlugReserved } from '@afilmory/utils' +import { RESERVED_TENANT_SLUGS } from '@afilmory/utils' import { DbAccessor } from 'core/database/database.provider' import { BizException, ErrorCode } from 'core/errors' import type { BillingPlanId } from 'core/modules/platform/billing/billing-plan.types' -import { desc, eq } from 'drizzle-orm' +import { and, asc, count, desc, eq, notInArray } from 'drizzle-orm' import { injectable } from 'tsyringe' -import type { TenantAggregate } from './tenant.types' +import type { TenantAggregate, TenantRecord } from './tenant.types' @injectable() export class TenantRepository { @@ -76,11 +76,45 @@ export class TenantRepository { await db.update(tenants).set({ banned, updatedAt: new Date().toISOString() }).where(eq(tenants.id, id)) } - async listTenants(): Promise { + async listTenants(options: { + page: number + limit: number + status?: TenantRecord['status'] + sortBy?: 'createdAt' | 'name' + sortDir?: 'asc' | 'desc' + }): Promise<{ items: TenantAggregate[]; total: number }> { const db = this.dbAccessor.get() - const rows = await db.select().from(tenants).orderBy(desc(tenants.createdAt)) + const { page, limit, status, sortBy = 'createdAt', sortDir = 'desc' } = options - // Ignore preseve slug - return rows.filter((tenant) => !isTenantSlugReserved(tenant.slug)).map((tenant) => ({ tenant })) + const conditions = [notInArray(tenants.slug, RESERVED_TENANT_SLUGS)] + + if (status) { + conditions.push(eq(tenants.status, status)) + } + + const where = and(...conditions) + + const [total] = await db.select({ count: count() }).from(tenants).where(where) + + let orderBy + const sortColumn = sortBy === 'name' ? tenants.name : tenants.createdAt + if (sortDir === 'asc') { + orderBy = asc(sortColumn) + } else { + orderBy = desc(sortColumn) + } + + const rows = await db + .select() + .from(tenants) + .where(where) + .limit(limit) + .offset((page - 1) * limit) + .orderBy(orderBy) + + return { + items: rows.map((tenant) => ({ tenant })), + total: total?.count ?? 0, + } } } diff --git a/be/apps/core/src/modules/platform/tenant/tenant.service.ts b/be/apps/core/src/modules/platform/tenant/tenant.service.ts index 8b016817..244349fb 100644 --- a/be/apps/core/src/modules/platform/tenant/tenant.service.ts +++ b/be/apps/core/src/modules/platform/tenant/tenant.service.ts @@ -115,8 +115,20 @@ export class TenantService { await this.repository.deleteById(id) } - async listTenants(): Promise { - return await this.repository.listTenants() + async listTenants(options?: { + page?: number + limit?: number + status?: TenantRecord['status'] + sortBy?: 'createdAt' | 'name' + sortDir?: 'asc' | 'desc' + }): Promise<{ items: TenantAggregate[]; total: number }> { + return await this.repository.listTenants({ + page: options?.page ?? 1, + limit: options?.limit ?? 20, + status: options?.status, + sortBy: options?.sortBy, + sortDir: options?.sortDir, + }) } async setBanned(id: string, banned: boolean): Promise { diff --git a/be/apps/dashboard/src/modules/auth/hooks/useLogin.ts b/be/apps/dashboard/src/modules/auth/hooks/useLogin.ts index fc10f65c..491d939a 100644 --- a/be/apps/dashboard/src/modules/auth/hooks/useLogin.ts +++ b/be/apps/dashboard/src/modules/auth/hooks/useLogin.ts @@ -99,6 +99,7 @@ export function useLogin() { } } } else { + console.error('error', error) setErrorMessage('An unexpected error occurred. Please try again') } }, diff --git a/be/apps/dashboard/src/modules/super-admin/api.ts b/be/apps/dashboard/src/modules/super-admin/api.ts index 59e7feea..272d4397 100644 --- a/be/apps/dashboard/src/modules/super-admin/api.ts +++ b/be/apps/dashboard/src/modules/super-admin/api.ts @@ -7,6 +7,7 @@ import type { BuilderDebugProgressEvent, BuilderDebugResult, SuperAdminSettingsResponse, + SuperAdminTenantListParams, SuperAdminTenantListResponse, SuperAdminTenantPhotosResponse, UpdateSuperAdminSettingsPayload, @@ -39,8 +40,22 @@ export async function updateSuperAdminSettings(payload: UpdateSuperAdminSettings }) } -export async function fetchSuperAdminTenants(): Promise { - const response = await coreApi(`${SUPER_ADMIN_TENANTS_ENDPOINT}`, { +export async function fetchSuperAdminTenants( + params?: SuperAdminTenantListParams, +): Promise { + const query = new URLSearchParams() + if (params) { + if (params.page) query.set('page', String(params.page)) + if (params.limit) query.set('limit', String(params.limit)) + if (params.status) query.set('status', params.status) + if (params.sortBy) query.set('sortBy', params.sortBy) + if (params.sortDir) query.set('sortDir', params.sortDir) + } + + const queryString = query.toString() + const url = queryString ? `${SUPER_ADMIN_TENANTS_ENDPOINT}?${queryString}` : SUPER_ADMIN_TENANTS_ENDPOINT + + const response = await coreApi(url, { method: 'GET', }) return camelCaseKeys(response) diff --git a/be/apps/dashboard/src/modules/super-admin/components/SuperAdminTenantManager.tsx b/be/apps/dashboard/src/modules/super-admin/components/SuperAdminTenantManager.tsx index 47938ebf..96e90c61 100644 --- a/be/apps/dashboard/src/modules/super-admin/components/SuperAdminTenantManager.tsx +++ b/be/apps/dashboard/src/modules/super-admin/components/SuperAdminTenantManager.tsx @@ -1,7 +1,8 @@ import { Button, Modal, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@afilmory/ui' import { Spring } from '@afilmory/utils' -import { RefreshCcwIcon } from 'lucide-react' +import { ArrowDownIcon, ArrowUpIcon, ChevronLeftIcon, ChevronRightIcon, RefreshCcwIcon } from 'lucide-react' import { m } from 'motion/react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -18,7 +19,19 @@ const DATE_FORMATTER = new Intl.DateTimeFormat('zh-CN', { }) export function SuperAdminTenantManager() { - const tenantsQuery = useSuperAdminTenantsQuery() + const [page, setPage] = useState(1) + const [limit] = useState(10) + const [status, setStatus] = useState('all') + const [sortBy, setSortBy] = useState('createdAt') + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc') + + const tenantsQuery = useSuperAdminTenantsQuery({ + page, + limit, + status: status === 'all' ? undefined : status, + sortBy, + sortDir, + }) const updatePlanMutation = useUpdateTenantPlanMutation() const updateBanMutation = useUpdateTenantBanMutation() const { t } = useTranslation() @@ -29,6 +42,17 @@ export function SuperAdminTenantManager() { const plans = data?.plans ?? [] const tenants = data?.tenants ?? [] + const total = data?.total ?? 0 + const totalPages = Math.ceil(total / limit) + + const handleSort = (field: string) => { + if (sortBy === field) { + setSortDir(sortDir === 'asc' ? 'desc' : 'asc') + } else { + setSortBy(field) + setSortDir('asc') + } + } const handlePlanChange = (tenant: SuperAdminTenantSummary, planId: string) => { if (planId === tenant.planId) { @@ -90,9 +114,34 @@ export function SuperAdminTenantManager() { return } + const SortIcon = ({ field }: { field: string }) => { + if (sortBy !== field) return null + return sortDir === 'asc' ? : + } + return ( -
+
+
+ +
- + {tenants.length === 0 ? (

{t('superadmin.tenants.empty')}

) : ( @@ -116,12 +165,28 @@ export function SuperAdminTenantManager() { - + - + @@ -180,6 +245,39 @@ export function SuperAdminTenantManager() { ))}
{t('superadmin.tenants.table.tenant')} handleSort('name')} + > +
+ {t('superadmin.tenants.table.tenant')} + +
+
{t('superadmin.tenants.table.plan')} {t('superadmin.tenants.table.usage')} {t('superadmin.tenants.table.status')} {t('superadmin.tenants.table.ban')}{t('superadmin.tenants.table.created')} handleSort('createdAt')} + > +
+ {t('superadmin.tenants.table.created')} + +
+
+ +
+
+ {t('superadmin.tenants.pagination.showing', { + start: (page - 1) * limit + 1, + end: Math.min(page * limit, total), + total, + })} +
+
+ +
+ {page} / {totalPages || 1} +
+ +
+
)}
@@ -248,6 +346,13 @@ function StatusBadge({ status, banned }: { status: SuperAdminTenantSummary['stat ) } + if (status === 'pending') { + return ( + + {t('superadmin.tenants.status.pending')} + + ) + } return ( {t('superadmin.tenants.status.inactive')} diff --git a/be/apps/dashboard/src/modules/super-admin/hooks.ts b/be/apps/dashboard/src/modules/super-admin/hooks.ts index d1fd7ac2..ccd405a4 100644 --- a/be/apps/dashboard/src/modules/super-admin/hooks.ts +++ b/be/apps/dashboard/src/modules/super-admin/hooks.ts @@ -10,6 +10,7 @@ import { } from './api' import type { SuperAdminSettingsResponse, + SuperAdminTenantListParams, SuperAdminTenantListResponse, SuperAdminTenantPhotosResponse, UpdateSuperAdminSettingsPayload, @@ -28,10 +29,11 @@ export function useSuperAdminSettingsQuery() { }) } -export function useSuperAdminTenantsQuery() { +export function useSuperAdminTenantsQuery(params?: SuperAdminTenantListParams) { return useQuery({ - queryKey: SUPER_ADMIN_TENANTS_QUERY_KEY, - queryFn: fetchSuperAdminTenants, + queryKey: [...SUPER_ADMIN_TENANTS_QUERY_KEY, params], + queryFn: () => fetchSuperAdminTenants(params), + placeholderData: (previousData) => previousData, }) } diff --git a/be/apps/dashboard/src/modules/super-admin/types.ts b/be/apps/dashboard/src/modules/super-admin/types.ts index 4cd42b63..9cf9ff73 100644 --- a/be/apps/dashboard/src/modules/super-admin/types.ts +++ b/be/apps/dashboard/src/modules/super-admin/types.ts @@ -112,6 +112,15 @@ export interface SuperAdminTenantSummary { export interface SuperAdminTenantListResponse { tenants: SuperAdminTenantSummary[] plans: BillingPlanDefinition[] + total: number +} + +export interface SuperAdminTenantListParams { + page: number + limit: number + status?: string + sortBy?: string + sortDir?: 'asc' | 'desc' } export interface UpdateTenantPlanPayload { diff --git a/be/apps/dashboard/src/pages/(onboarding)/root-login.tsx b/be/apps/dashboard/src/pages/(onboarding)/root-login.tsx index a02d9b3f..2918159e 100644 --- a/be/apps/dashboard/src/pages/(onboarding)/root-login.tsx +++ b/be/apps/dashboard/src/pages/(onboarding)/root-login.tsx @@ -13,6 +13,10 @@ export function Component() { const [isRedirecting, setIsRedirecting] = useState(false) const { login, isLoading, error, clearError } = useLogin() + useEffect(() => { + console.error('error', error) + }, [error]) + const tenantSlug = useMemo(() => { return getTenantSlugFromHost(window.location.hostname) }, []) diff --git a/be/packages/db/scripts/fix-migration-8.sql b/be/packages/db/scripts/fix-migration-8.sql index 738e3f9d..2b0c43e0 100644 --- a/be/packages/db/scripts/fix-migration-8.sql +++ b/be/packages/db/scripts/fix-migration-8.sql @@ -45,3 +45,5 @@ SELECT * FROM drizzle.__drizzle_migrations WHERE id = 7; */ + + diff --git a/locales/dashboard/en.json b/locales/dashboard/en.json index 57acd66b..2c9bdbd0 100644 --- a/locales/dashboard/en.json +++ b/locales/dashboard/en.json @@ -826,6 +826,8 @@ "superadmin.tenants.description": "Switch plans for specific tenants or suspend those that violate policies.", "superadmin.tenants.empty": "There are no tenants to manage right now.", "superadmin.tenants.error.loading": "Unable to load tenant data: {{reason}}", + "superadmin.tenants.filter.all": "All", + "superadmin.tenants.filter.status": "Status", "superadmin.tenants.modal.overview.details": "Details", "superadmin.tenants.modal.overview.photos": "Total Photos", "superadmin.tenants.modal.photos.empty": "No photos found for this tenant.", @@ -837,12 +839,14 @@ "superadmin.tenants.modal.photos.table.size": "Size", "superadmin.tenants.modal.tab.overview": "Overview", "superadmin.tenants.modal.tab.photos": "Photos", + "superadmin.tenants.pagination.showing": "Showing {{start}}-{{end}} of {{total}}", "superadmin.tenants.plan.placeholder": "Select a plan", "superadmin.tenants.refresh.button": "Refresh list", "superadmin.tenants.refresh.loading": "Refreshing…", "superadmin.tenants.status.active": "Active", "superadmin.tenants.status.banned": "Banned", "superadmin.tenants.status.inactive": "Inactive", + "superadmin.tenants.status.pending": "Pending", "superadmin.tenants.status.suspended": "Suspended", "superadmin.tenants.table.ban": "Ban", "superadmin.tenants.table.created": "Created", diff --git a/locales/dashboard/zh-CN.json b/locales/dashboard/zh-CN.json index 5f819546..3aee813b 100644 --- a/locales/dashboard/zh-CN.json +++ b/locales/dashboard/zh-CN.json @@ -825,16 +825,20 @@ "superadmin.tenants.description": "为特定租户切换订阅计划或封禁违规租户。", "superadmin.tenants.empty": "当前没有可管理的租户。", "superadmin.tenants.error.loading": "无法加载租户数据:{{reason}}", + "superadmin.tenants.filter.all": "全部", + "superadmin.tenants.filter.status": "状态筛选", "superadmin.tenants.modal.overview.details": "详细信息", "superadmin.tenants.modal.overview.photos": "照片总数", "superadmin.tenants.modal.tab.overview": "概览", "superadmin.tenants.modal.tab.photos": "照片", + "superadmin.tenants.pagination.showing": "显示 {{start}}-{{end}} 项,共 {{total}} 项", "superadmin.tenants.plan.placeholder": "选择订阅计划", "superadmin.tenants.refresh.button": "刷新列表", "superadmin.tenants.refresh.loading": "正在刷新…", "superadmin.tenants.status.active": "活跃", "superadmin.tenants.status.banned": "已封禁", "superadmin.tenants.status.inactive": "未激活", + "superadmin.tenants.status.pending": "待处理", "superadmin.tenants.status.suspended": "已暂停", "superadmin.tenants.table.ban": "封禁", "superadmin.tenants.table.created": "创建时间",