feat: implement tenant suspension handling and deletion functionality

- Added a new tenant suspension page and related components to inform users when their tenant is suspended due to policy violations.
- Implemented deletion functionality for tenants in the SuperAdminTenantManager, allowing super admins to permanently delete tenant accounts.
- Updated localization files to include new strings for tenant suspension and deletion prompts.
- Enhanced the DataManagementService to handle tenant deletion with metadata checks.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-12-05 15:28:21 +08:00
parent b8f1a26ce2
commit b42bfbc520
22 changed files with 356 additions and 58 deletions

View File

@@ -8,6 +8,7 @@ import { STATIC_DASHBOARD_BASENAME } from './static-dashboard.service'
const TENANT_MISSING_ENTRY_PATH = `${STATIC_DASHBOARD_BASENAME}/tenant-missing.html`
const TENANT_RESTRICTED_ENTRY_PATH = `${STATIC_DASHBOARD_BASENAME}/tenant-restricted.html`
const TENANT_SUSPENDED_ENTRY_PATH = `${STATIC_DASHBOARD_BASENAME}/tenant-suspended.html`
export const StaticControllerUtils = {
cloneResponseWithStatus(response: Response, status: number): Response {
@@ -46,9 +47,29 @@ export const StaticControllerUtils = {
return isTenantSlugReserved(tenantSlug)
},
shouldRenderTenantRestrictedPage(): boolean {
return StaticControllerUtils.isReservedTenant({ root: true })
},
shouldRenderTenantSuspendedPage(): boolean {
const tenantContext = getTenantContext()
if (!tenantContext) {
return false
}
return tenantContext.tenant.banned || tenantContext.tenant.status === 'suspended'
},
shouldRenderTenantMissingPage(): boolean {
const tenantContext = getTenantContext()
return !tenantContext || isPlaceholderTenantContext(tenantContext)
if (!tenantContext) {
return true
}
if (tenantContext.tenant.banned || tenantContext.tenant.status === 'suspended') {
return false
}
return isPlaceholderTenantContext(tenantContext)
},
async renderTenantMissingPage(dashboardService: StaticDashboardService): Promise<Response> {
@@ -72,4 +93,28 @@ export const StaticControllerUtils = {
message: 'Workspace access restricted',
})
},
async renderTenantSuspendedPage(dashboardService: StaticDashboardService): Promise<Response> {
const response = await dashboardService.handleRequest(TENANT_SUSPENDED_ENTRY_PATH, false)
if (response) {
return StaticControllerUtils.cloneResponseWithStatus(response, 403)
}
throw new BizException(ErrorCode.COMMON_FORBIDDEN, {
message: 'Workspace suspended',
})
},
async ensureTenantAvailable(dashboardService: StaticDashboardService): Promise<Response | null> {
if (StaticControllerUtils.shouldRenderTenantRestrictedPage()) {
return await StaticControllerUtils.renderTenantRestrictedPage(dashboardService)
}
if (StaticControllerUtils.shouldRenderTenantSuspendedPage()) {
return await StaticControllerUtils.renderTenantSuspendedPage(dashboardService)
}
if (StaticControllerUtils.shouldRenderTenantMissingPage()) {
return await StaticControllerUtils.renderTenantMissingPage(dashboardService)
}
return null
},
}

View File

@@ -1,4 +1,5 @@
import { ContextParam, Controller, createZodSchemaDto, Get, Param, Query } from '@afilmory/framework'
import { AllowPlaceholderTenant } from 'core/decorators/allow-placeholder.decorator'
import { SkipTenantGuard } from 'core/decorators/skip-tenant.decorator'
import type { Context } from 'hono'
import z from 'zod'
@@ -23,16 +24,15 @@ export class StaticWebController extends StaticBaseController {
@Get('/')
@Get('/explory')
@SkipTenantGuard()
@AllowPlaceholderTenant()
async getStaticWebIndex(@ContextParam() context: Context, @Query() query: StaticWebDto) {
if (query.dev) {
return await this.serveDev(context, query.dev.toString())
}
if (StaticControllerUtils.isReservedTenant({ root: true })) {
return await StaticControllerUtils.renderTenantRestrictedPage(this.staticDashboardService)
}
if (StaticControllerUtils.shouldRenderTenantMissingPage()) {
return await StaticControllerUtils.renderTenantMissingPage(this.staticDashboardService)
const tenantStateResponse = await StaticControllerUtils.ensureTenantAvailable(this.staticDashboardService)
if (tenantStateResponse) {
return tenantStateResponse
}
const response = await this.serve(context, this.staticWebService, false)
@@ -44,11 +44,9 @@ export class StaticWebController extends StaticBaseController {
@Get('/photos/:photoId')
async getStaticPhotoPage(@ContextParam() context: Context, @Param('photoId') photoId: string) {
if (StaticControllerUtils.isReservedTenant({ root: true })) {
return await StaticControllerUtils.renderTenantRestrictedPage(this.staticDashboardService)
}
if (StaticControllerUtils.shouldRenderTenantMissingPage()) {
return await StaticControllerUtils.renderTenantMissingPage(this.staticDashboardService)
const tenantStateResponse = await StaticControllerUtils.ensureTenantAvailable(this.staticDashboardService)
if (tenantStateResponse) {
return tenantStateResponse
}
const response = await this.serve(context, this.staticWebService, false)
if (response.status === 404) {

View File

@@ -62,44 +62,31 @@ export class DataManagementService {
async deleteTenantAccount(): Promise<{ deletedTenantId: string }> {
const tenant = requireTenantContext()
const tenantId = tenant.tenant.id
const tenantSlug = tenant.tenant.slug
await this.deleteManagedStorageSpace(tenantId)
if (!tenantSlug) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
message: '当前租户缺少 slug无法删除账户。',
})
}
if (tenantSlug === ROOT_TENANT_SLUG || tenant.tenant.status === 'pending') {
throw new BizException(ErrorCode.AUTH_FORBIDDEN, {
message: '系统租户或未完成初始化的工作区无法通过此操作删除。',
})
}
await this.deleteManagedStorageSpace(tenantId)
const db = this.dbAccessor.get()
await db.transaction(async (tx) => {
await tx.delete(photoAssets).where(eq(photoAssets.tenantId, tenantId))
await tx.delete(reactions).where(eq(reactions.tenantId, tenantId))
await tx.delete(settings).where(eq(settings.tenantId, tenantId))
await tx.delete(tenantDomains).where(eq(tenantDomains.tenantId, tenantId))
await tx.delete(comments).where(eq(comments.tenantId, tenantId))
await tx.delete(commentReactions).where(eq(commentReactions.tenantId, tenantId))
await tx.delete(authSessions).where(eq(authSessions.tenantId, tenantId))
// await tx.delete(authAccounts).where(eq(authAccounts.tenantId, tenantId))
await tx.delete(authUsers).where(eq(authUsers.tenantId, tenantId))
await tx.delete(tenants).where(eq(tenants.id, tenantId))
await this.deleteTenantWithMetadata({
tenantId,
tenantSlug: tenant.tenant.slug,
status: tenant.tenant.status,
})
return {
deletedTenantId: tenantId,
return { deletedTenantId: tenantId }
}
async deleteTenantAccountById(tenantId: string): Promise<{ deletedTenantId: string }> {
const db = this.dbAccessor.get()
const [record] = await db.select().from(tenants).where(eq(tenants.id, tenantId)).limit(1)
if (!record) {
throw new BizException(ErrorCode.TENANT_NOT_FOUND)
}
await this.deleteTenantWithMetadata({
tenantId,
tenantSlug: record.slug,
status: record.status,
})
return { deletedTenantId: tenantId }
}
private async deleteManagedStorageSpace(tenantId: string): Promise<void> {
@@ -129,4 +116,40 @@ export class DataManagementService {
upstream: upstream as RemoteStorageConfig,
}
}
private assertTenantDeletable(tenantSlug: string | null, status: string): void {
if (!tenantSlug) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, {
message: '当前租户缺少 slug无法删除账户。',
})
}
if (tenantSlug === ROOT_TENANT_SLUG || status === 'pending') {
throw new BizException(ErrorCode.AUTH_FORBIDDEN, {
message: '系统租户或未完成初始化的工作区无法通过此操作删除。',
})
}
}
private async deleteTenantWithMetadata(options: { tenantId: string; tenantSlug: string | null; status: string }) {
this.assertTenantDeletable(options.tenantSlug, options.status)
await this.deleteManagedStorageSpace(options.tenantId)
const db = this.dbAccessor.get()
await db.transaction(async (tx) => {
await tx.delete(photoAssets).where(eq(photoAssets.tenantId, options.tenantId))
await tx.delete(reactions).where(eq(reactions.tenantId, options.tenantId))
await tx.delete(settings).where(eq(settings.tenantId, options.tenantId))
await tx.delete(tenantDomains).where(eq(tenantDomains.tenantId, options.tenantId))
await tx.delete(comments).where(eq(comments.tenantId, options.tenantId))
await tx.delete(commentReactions).where(eq(commentReactions.tenantId, options.tenantId))
await tx.delete(authSessions).where(eq(authSessions.tenantId, options.tenantId))
await tx.delete(authUsers).where(eq(authUsers.tenantId, options.tenantId))
await tx.delete(tenants).where(eq(tenants.id, options.tenantId))
})
}
}

View File

@@ -1,5 +1,5 @@
import { photoAssets } from '@afilmory/db'
import { Body, Controller, Get, Param, Patch, Query } from '@afilmory/framework'
import { Body, Controller, Delete, Get, Param, Patch, Query } from '@afilmory/framework'
import { DbAccessor } from 'core/database/database.provider'
import { Roles } from 'core/guards/roles.decorator'
import { BypassResponseTransform } from 'core/interceptors/response-transform.decorator'
@@ -9,6 +9,7 @@ import { TenantService } from 'core/modules/platform/tenant/tenant.service'
import { desc, eq } from 'drizzle-orm'
import type { BillingPlanId } from '../billing/billing-plan.types'
import { DataManagementService } from '../data-management/data-management.service'
import { UpdateTenantBanDto, UpdateTenantPlanDto } from './super-admin.dto'
@Controller('super-admin/tenants')
@@ -17,6 +18,7 @@ import { UpdateTenantBanDto, UpdateTenantPlanDto } from './super-admin.dto'
export class SuperAdminTenantController {
constructor(
private readonly tenantService: TenantService,
private readonly dataManagementService: DataManagementService,
private readonly billingPlanService: BillingPlanService,
private readonly billingUsageService: BillingUsageService,
private readonly db: DbAccessor,
@@ -85,4 +87,9 @@ export class SuperAdminTenantController {
await this.tenantService.setBanned(tenantId, dto.banned)
return { updated: true }
}
@Delete('/:tenantId')
async deleteTenant(@Param('tenantId') tenantId: string) {
return await this.dataManagementService.deleteTenantAccountById(tenantId)
}
}

View File

@@ -142,7 +142,11 @@ export class TenantService {
}
const existing = await this.repository.findBySlug(normalized)
return existing === null
if (!existing) {
return true
}
return existing.tenant.status === 'pending'
}
ensureTenantIsActive(tenant: TenantAggregate['tenant'], options?: { allowPending?: boolean }): void {

View File

@@ -0,0 +1,19 @@
import '../styles/index.css'
import { createRoot } from 'react-dom/client'
import { I18nProvider } from '~/providers/i18n-provider'
import { TenantSuspendedStandalone } from '../modules/welcome/components/TenantSuspendedStandalone'
const root = document.querySelector('#root')
if (!root) {
throw new Error('Root element not found for tenant suspended entry.')
}
createRoot(root).render(
<I18nProvider>
<TenantSuspendedStandalone />
</I18nProvider>,
)

View File

@@ -75,6 +75,12 @@ export async function updateSuperAdminTenantBan(payload: UpdateTenantBanPayload)
})
}
export async function deleteSuperAdminTenant(tenantId: string): Promise<void> {
await coreApi(`${SUPER_ADMIN_TENANTS_ENDPOINT}/${tenantId}`, {
method: 'DELETE',
})
}
export async function runBuilderDebugTest(file: File, options?: RunBuilderDebugOptions): Promise<BuilderDebugResult> {
const formData = new FormData()
formData.append('file', file)

View File

@@ -1,4 +1,4 @@
import { Button, Modal, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@afilmory/ui'
import { Button, Modal, Prompt, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@afilmory/ui'
import { Spring } from '@afilmory/utils'
import { ArrowDownIcon, ArrowUpIcon, ChevronLeftIcon, ChevronRightIcon, RefreshCcwIcon } from 'lucide-react'
import { m } from 'motion/react'
@@ -7,8 +7,14 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { LinearBorderPanel } from '~/components/common/LinearBorderPanel'
import { getRequestErrorMessage } from '~/lib/errors'
import { useSuperAdminTenantsQuery, useUpdateTenantBanMutation, useUpdateTenantPlanMutation } from '../hooks'
import {
useDeleteTenantMutation,
useSuperAdminTenantsQuery,
useUpdateTenantBanMutation,
useUpdateTenantPlanMutation,
} from '../hooks'
import type { BillingPlanDefinition, SuperAdminTenantSummary } from '../types'
import { TenantDetailModal } from './TenantDetailModal'
import { TenantUsageCell } from './TenantUsageCell'
@@ -34,6 +40,7 @@ export function SuperAdminTenantManager() {
})
const updatePlanMutation = useUpdateTenantPlanMutation()
const updateBanMutation = useUpdateTenantBanMutation()
const deleteTenantMutation = useDeleteTenantMutation()
const { t } = useTranslation()
const { isLoading } = tenantsQuery
@@ -100,6 +107,40 @@ export function SuperAdminTenantManager() {
const isBanUpdating = (tenantId: string) =>
updateBanMutation.isPending && updateBanMutation.variables?.tenantId === tenantId
const isDeleting = (tenantId: string) => deleteTenantMutation.isPending && deleteTenantMutation.variables === tenantId
const handleDeleteTenant = (tenant: SuperAdminTenantSummary) => {
const requiredSlug = tenant.slug.trim()
Prompt.input({
title: t('superadmin.tenants.prompt.delete.title'),
description: t('superadmin.tenants.prompt.delete.description', { name: tenant.name }),
placeholder: t('superadmin.tenants.prompt.delete.placeholder', { slug: requiredSlug }),
variant: 'danger',
onConfirmText: t('superadmin.tenants.prompt.delete.confirm'),
onCancelText: t('superadmin.tenants.prompt.delete.cancel'),
onConfirm: (input) => {
const normalized = input.trim()
if (normalized !== requiredSlug) {
toast.error(t('superadmin.tenants.prompt.delete.mismatch'), {
description: t('superadmin.tenants.prompt.delete.placeholder', { slug: requiredSlug }),
})
return
}
deleteTenantMutation.mutate(tenant.id, {
onSuccess: () => {
toast.success(t('superadmin.tenants.toast.delete-success', { name: tenant.name }))
},
onError: (error) => {
const description = getRequestErrorMessage(error, t('common.retry-later'))
toast.error(t('superadmin.tenants.toast.delete-error'), { description })
},
})
},
})
}
if (isError) {
return (
<LinearBorderPanel className="p-6 text-sm text-red">
@@ -187,6 +228,7 @@ export function SuperAdminTenantManager() {
<SortIcon field="createdAt" />
</div>
</th>
<th className="px-3 py-2 text-right">{t('superadmin.tenants.table.actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/20">
@@ -241,6 +283,19 @@ export function SuperAdminTenantManager() {
<td className="px-3 py-3 align-top text-text-secondary text-xs">
{formatDateLabel(tenant.createdAt)}
</td>
<td className="px-3 py-2 align-top text-right">
<Button
type="button"
size="sm"
variant="destructive"
disabled={isDeleting(tenant.id)}
onClick={() => handleDeleteTenant(tenant)}
>
{isDeleting(tenant.id)
? t('superadmin.tenants.button.processing')
: t('superadmin.tenants.button.delete')}
</Button>
</td>
</tr>
))}
</tbody>

View File

@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
deleteSuperAdminTenant,
fetchSuperAdminSettings,
fetchSuperAdminTenantPhotos,
fetchSuperAdminTenants,
@@ -79,6 +80,19 @@ export function useUpdateTenantBanMutation() {
})
}
export function useDeleteTenantMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (tenantId: string) => {
await deleteSuperAdminTenant(tenantId)
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: SUPER_ADMIN_TENANTS_QUERY_KEY })
},
})
}
export function useSuperAdminTenantPhotosQuery(tenantId: string | undefined) {
return useQuery<SuperAdminTenantPhotosResponse>({
queryKey: [...SUPER_ADMIN_TENANTS_QUERY_KEY, tenantId, 'photos'],

View File

@@ -0,0 +1,62 @@
import { Button, LinearBorderContainer } from '@afilmory/ui'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { buildHomeUrl, getCurrentHostname } from './tenant-utils'
export const TenantSuspendedStandalone = () => {
const { t } = useTranslation()
const hostname = useMemo(() => getCurrentHostname(), [])
const homeUrl = useMemo(() => buildHomeUrl(), [])
return (
<div className="relative flex min-h-dvh flex-1 flex-col bg-background text-text">
<div className="flex flex-1 items-center justify-center px-4 py-10 sm:px-6">
<LinearBorderContainer>
<div className="relative w-full max-w-[640px] overflow-hidden border border-white/5">
<div className="pointer-events-none absolute inset-0 opacity-60">
<div className="absolute -inset-32 bg-linear-to-br from-accent/20 via-transparent to-transparent blur-3xl" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.08),transparent_55%)]" />
</div>
<div className="relative p-10 sm:p-12">
<div>
<p className="text-text-tertiary mb-3 text-xs font-semibold uppercase tracking-[0.55em]">
{t('welcome.tenant-suspended.code')}
</p>
<h1 className="mb-4 text-3xl font-bold tracking-tight sm:text-4xl">
{t('welcome.tenant-suspended.title')}
</h1>
<p className="text-text-secondary mb-6 text-base leading-relaxed">
{t('welcome.tenant-suspended.description')}
</p>
{hostname && (
<div className="bg-material-medium/40 border-fill-tertiary mb-6 rounded-2xl border px-5 py-4 text-sm">
<p className="text-text-secondary">
{t('welcome.tenant-missing.request')}
<span className="text-text font-medium">{hostname}</span>
</p>
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row">
<Button
variant="primary"
className="glassmorphic-btn flex-1"
onClick={() => (window.location.href = 'mailto:support@afilmory.com')}
>
{t('welcome.tenant-suspended.contact')}
</Button>
<Button variant="ghost" className="flex-1" onClick={() => (window.location.href = homeUrl)}>
{t('welcome.tenant-suspended.home')}
</Button>
</div>
</div>
</div>
</div>
</LinearBorderContainer>
</div>
</div>
)
}

View File

@@ -0,0 +1,26 @@
<!doctype html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="description" content="当前空间因违规或管理原因已被暂停。" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Afilmory - 空间已暂停</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap"
rel="stylesheet"
referrerpolicy="no-referrer"
/>
<link rel="shortcut icon" href="/favicon.ico" />
<style>
html {
font-family: 'Geist', ui-sans-serif, system-ui, sans-serif;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/entries/tenant-suspended.tsx"></script>
</body>
</html>

View File

@@ -89,6 +89,7 @@ export default defineConfig({
main: resolve(ROOT, 'index.html'),
'tenant-missing': resolve(ROOT, 'tenant-missing.html'),
'tenant-restricted': resolve(ROOT, 'tenant-restricted.html'),
'tenant-suspended': resolve(ROOT, 'tenant-suspended.html'),
},
},
},

View File

@@ -47,3 +47,4 @@ SELECT * FROM drizzle.__drizzle_migrations WHERE id = 7;