mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
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:
@@ -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-12-04T15:54:49+08:00
|
||||
lastModified: 2025-12-05T15:28:22+08:00
|
||||
order: 2
|
||||
---
|
||||
|
||||
@@ -114,3 +114,4 @@ Deploy to Vercel or any Node.js host. See [Vercel Deployment](/deployment/vercel
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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-12-04T15:54:49+08:00
|
||||
lastModified: 2025-12-05T15:28:22+08:00
|
||||
order: 33
|
||||
---
|
||||
|
||||
@@ -97,3 +97,4 @@ Compare with AWS S3 to see which fits your usage pattern better.
|
||||
- Reduce concurrency if needed
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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-12-04T15:54:49+08:00
|
||||
lastModified: 2025-12-05T15:28:22+08:00
|
||||
order: 36
|
||||
---
|
||||
|
||||
@@ -168,3 +168,4 @@ This creates tags in the manifest based on folder structure, useful for organizi
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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-12-04T15:54:49+08:00
|
||||
lastModified: 2025-12-05T15:28:22+08:00
|
||||
order: 34
|
||||
---
|
||||
|
||||
@@ -134,3 +134,4 @@ For private repositories:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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-12-04T15:54:49+08:00
|
||||
lastModified: 2025-12-05T15:28:22+08:00
|
||||
order: 30
|
||||
---
|
||||
|
||||
@@ -114,3 +114,4 @@ See each provider's documentation for specific configuration options.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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-12-04T15:54:49+08:00
|
||||
lastModified: 2025-12-05T15:28:22+08:00
|
||||
order: 35
|
||||
---
|
||||
|
||||
@@ -137,3 +137,4 @@ If you want to serve original photos:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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-12-04T15:54:49+08:00
|
||||
lastModified: 2025-12-05T15:28:22+08:00
|
||||
order: 32
|
||||
---
|
||||
|
||||
@@ -124,3 +124,4 @@ This prevents processing temporary or system files.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
19
be/apps/dashboard/src/entries/tenant-suspended.tsx
Normal file
19
be/apps/dashboard/src/entries/tenant-suspended.tsx
Normal 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>,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
26
be/apps/dashboard/tenant-suspended.html
Normal file
26
be/apps/dashboard/tenant-suspended.html
Normal 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>
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -47,3 +47,4 @@ SELECT * FROM drizzle.__drizzle_migrations WHERE id = 7;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -821,6 +821,7 @@
|
||||
"superadmin.settings.tabs.managed-storage": "Managed Storage",
|
||||
"superadmin.settings.title": "System Settings",
|
||||
"superadmin.tenants.button.ban": "Ban",
|
||||
"superadmin.tenants.button.delete": "Delete",
|
||||
"superadmin.tenants.button.processing": "Working…",
|
||||
"superadmin.tenants.button.unban": "Unban",
|
||||
"superadmin.tenants.description": "Switch plans for specific tenants or suspend those that violate policies.",
|
||||
@@ -841,6 +842,12 @@
|
||||
"superadmin.tenants.modal.tab.photos": "Photos",
|
||||
"superadmin.tenants.pagination.showing": "Showing {{start}}-{{end}} of {{total}}",
|
||||
"superadmin.tenants.plan.placeholder": "Select a plan",
|
||||
"superadmin.tenants.prompt.delete.cancel": "Keep tenant",
|
||||
"superadmin.tenants.prompt.delete.confirm": "Delete tenant",
|
||||
"superadmin.tenants.prompt.delete.description": "This will permanently delete {{name}} and all related data. Type the slug to confirm.",
|
||||
"superadmin.tenants.prompt.delete.mismatch": "The slug does not match. Deletion canceled.",
|
||||
"superadmin.tenants.prompt.delete.placeholder": "Enter slug: {{slug}}",
|
||||
"superadmin.tenants.prompt.delete.title": "Delete tenant",
|
||||
"superadmin.tenants.refresh.button": "Refresh list",
|
||||
"superadmin.tenants.refresh.loading": "Refreshing…",
|
||||
"superadmin.tenants.status.active": "Active",
|
||||
@@ -848,6 +855,7 @@
|
||||
"superadmin.tenants.status.inactive": "Inactive",
|
||||
"superadmin.tenants.status.pending": "Pending",
|
||||
"superadmin.tenants.status.suspended": "Suspended",
|
||||
"superadmin.tenants.table.actions": "Actions",
|
||||
"superadmin.tenants.table.ban": "Ban",
|
||||
"superadmin.tenants.table.created": "Created",
|
||||
"superadmin.tenants.table.plan": "Plan",
|
||||
@@ -857,6 +865,8 @@
|
||||
"superadmin.tenants.title": "Tenant Subscription Management",
|
||||
"superadmin.tenants.toast.ban-error": "Failed to update ban status.",
|
||||
"superadmin.tenants.toast.ban-success": "Tenant {{name}} has been banned.",
|
||||
"superadmin.tenants.toast.delete-error": "Failed to delete tenant.",
|
||||
"superadmin.tenants.toast.delete-success": "Tenant {{name}} has been deleted.",
|
||||
"superadmin.tenants.toast.plan-error": "Failed to update subscription plan.",
|
||||
"superadmin.tenants.toast.plan-success": "{{name}} switched to the {{planId}} plan.",
|
||||
"superadmin.tenants.toast.unban-success": "Tenant {{name}} is no longer banned.",
|
||||
@@ -872,5 +882,10 @@
|
||||
"welcome.tenant-restricted.home": "Back to home",
|
||||
"welcome.tenant-restricted.register": "Create a new space",
|
||||
"welcome.tenant-restricted.request": "Requested host:",
|
||||
"welcome.tenant-restricted.title": "Space Reserved"
|
||||
"welcome.tenant-restricted.title": "Space Reserved",
|
||||
"welcome.tenant-suspended.code": "403",
|
||||
"welcome.tenant-suspended.contact": "Contact Support",
|
||||
"welcome.tenant-suspended.description": "This space has been suspended due to a violation of our terms of service or other administrative action. If you believe this is an error, please contact support.",
|
||||
"welcome.tenant-suspended.home": "Back to home",
|
||||
"welcome.tenant-suspended.title": "Space Suspended"
|
||||
}
|
||||
|
||||
@@ -820,6 +820,7 @@
|
||||
"superadmin.settings.tabs.managed-storage": "托管存储",
|
||||
"superadmin.settings.title": "系统设置",
|
||||
"superadmin.tenants.button.ban": "封禁",
|
||||
"superadmin.tenants.button.delete": "删除",
|
||||
"superadmin.tenants.button.processing": "处理中…",
|
||||
"superadmin.tenants.button.unban": "解除封禁",
|
||||
"superadmin.tenants.description": "为特定租户切换订阅计划或封禁违规租户。",
|
||||
@@ -833,6 +834,12 @@
|
||||
"superadmin.tenants.modal.tab.photos": "照片",
|
||||
"superadmin.tenants.pagination.showing": "显示 {{start}}-{{end}} 项,共 {{total}} 项",
|
||||
"superadmin.tenants.plan.placeholder": "选择订阅计划",
|
||||
"superadmin.tenants.prompt.delete.cancel": "取消删除",
|
||||
"superadmin.tenants.prompt.delete.confirm": "确认删除",
|
||||
"superadmin.tenants.prompt.delete.description": "此操作将永久删除 {{name}} 及其所有数据。请输入租户 slug 以确认。",
|
||||
"superadmin.tenants.prompt.delete.mismatch": "Slug 不匹配,已取消删除。",
|
||||
"superadmin.tenants.prompt.delete.placeholder": "请输入 slug:{{slug}}",
|
||||
"superadmin.tenants.prompt.delete.title": "删除租户",
|
||||
"superadmin.tenants.refresh.button": "刷新列表",
|
||||
"superadmin.tenants.refresh.loading": "正在刷新…",
|
||||
"superadmin.tenants.status.active": "活跃",
|
||||
@@ -840,6 +847,7 @@
|
||||
"superadmin.tenants.status.inactive": "未激活",
|
||||
"superadmin.tenants.status.pending": "待处理",
|
||||
"superadmin.tenants.status.suspended": "已暂停",
|
||||
"superadmin.tenants.table.actions": "操作",
|
||||
"superadmin.tenants.table.ban": "封禁",
|
||||
"superadmin.tenants.table.created": "创建时间",
|
||||
"superadmin.tenants.table.plan": "订阅计划",
|
||||
@@ -849,6 +857,8 @@
|
||||
"superadmin.tenants.title": "租户订阅管理",
|
||||
"superadmin.tenants.toast.ban-error": "更新封禁状态失败",
|
||||
"superadmin.tenants.toast.ban-success": "已封禁租户 {{name}}",
|
||||
"superadmin.tenants.toast.delete-error": "删除租户失败",
|
||||
"superadmin.tenants.toast.delete-success": "已删除租户 {{name}}",
|
||||
"superadmin.tenants.toast.plan-error": "更新订阅失败",
|
||||
"superadmin.tenants.toast.plan-success": "已将 {{name}} 切换到 {{planId}} 计划",
|
||||
"superadmin.tenants.toast.unban-success": "已解除封禁 {{name}}",
|
||||
@@ -864,5 +874,10 @@
|
||||
"welcome.tenant-restricted.home": "返回首页",
|
||||
"welcome.tenant-restricted.register": "创建新空间",
|
||||
"welcome.tenant-restricted.request": "请求的主机:",
|
||||
"welcome.tenant-restricted.title": "空间已被保留"
|
||||
"welcome.tenant-restricted.title": "空间已被保留",
|
||||
"welcome.tenant-suspended.code": "403",
|
||||
"welcome.tenant-suspended.contact": "联系支持",
|
||||
"welcome.tenant-suspended.description": "由于违反服务条款或其他管理原因,该空间已被暂停。如您认为这是误判,请联系客服。",
|
||||
"welcome.tenant-suspended.home": "返回首页",
|
||||
"welcome.tenant-suspended.title": "空间已暂停"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user