From a1710924a8969f9b6adaf1790d48f5650998d1c4 Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 20 Nov 2025 20:54:12 +0800 Subject: [PATCH] feat: implement custom domain management for tenants - Introduced a new TenantDomainRepository and TenantDomainService to handle domain-related operations. - Added endpoints in TenantController for listing, requesting, verifying, and deleting custom domains. - Implemented domain verification logic with CNAME and TXT record support. - Enhanced UI components for domain management, including CustomDomainCard and DomainListItem. - Updated localization files to include new keys for domain management features. Signed-off-by: Innei --- .../core/src/middlewares/cors.middleware.ts | 4 +- .../modules/platform/auth/auth.provider.ts | 15 + .../tenant/tenant-context-resolver.service.ts | 35 +- .../tenant/tenant-domain.repository.ts | 126 ++ .../platform/tenant/tenant-domain.service.ts | 196 ++ .../platform/tenant/tenant.controller.ts | 43 +- .../modules/platform/tenant/tenant.module.ts | 4 +- .../modules/platform/tenant/tenant.service.ts | 2 +- .../modules/platform/tenant/tenant.types.ts | 7 +- be/apps/dashboard/.env.example | 3 +- .../{GlassPanel.tsx => LinearBorderPanel.tsx} | 0 be/apps/dashboard/src/lib/api-client.ts | 2 +- .../analytics/components/AnalyticsPage.tsx | 2 +- .../dashboard/src/modules/auth/auth-client.ts | 2 +- .../components/SocialConnectionSettings.tsx | 2 +- .../components/BuilderSettingsForm.tsx | 2 +- .../components/DashboardOverview.tsx | 2 +- .../components/DataManagementPanel.tsx | 2 +- .../components/library/PhotoLibraryGrid.tsx | 2 +- .../library/photo-upload/ProcessingPanel.tsx | 2 +- .../library/photo-upload/steps/ErrorStep.tsx | 2 +- .../library/photo-upload/steps/ReviewStep.tsx | 2 +- .../components/usage/PhotoUsagePanel.tsx | 2 +- .../schema-form/SchemaFormRenderer.tsx | 2 +- .../components/SettingsNavigation.tsx | 6 + .../src/modules/site-settings/api.ts | 25 + .../components/CustomDomainCard.tsx | 162 ++ .../site-settings/components/DomainBadge.tsx | 22 + .../components/DomainListItem.tsx | 64 + .../components/SiteSettingsForm.tsx | 2 +- .../components/SiteUserProfileForm.tsx | 2 +- .../src/modules/site-settings/hooks.ts | 87 +- .../src/modules/site-settings/index.ts | 1 + .../src/modules/site-settings/types.ts | 11 + .../components/StorageProvidersManager.tsx | 2 +- .../components/SuperAdminSettingsForm.tsx | 2 +- .../components/SuperAdminTenantManager.tsx | 2 +- be/apps/dashboard/src/pages/(main)/plan.tsx | 2 +- .../src/pages/(main)/settings/domain.tsx | 17 + .../dashboard/src/pages/superadmin/debug.tsx | 2 +- be/apps/dashboard/vite.config.ts | 3 +- .../db/migrations/0005_flawless_wild_pack.sql | 15 + .../db/migrations/meta/0005_snapshot.json | 1587 +++++++++++++++++ be/packages/db/migrations/meta/_journal.json | 7 + be/packages/db/src/schema.ts | 19 + be/session | 1 + locales/dashboard/en.json | 30 + locales/dashboard/zh-CN.json | 30 + 48 files changed, 2526 insertions(+), 36 deletions(-) create mode 100644 be/apps/core/src/modules/platform/tenant/tenant-domain.repository.ts create mode 100644 be/apps/core/src/modules/platform/tenant/tenant-domain.service.ts rename be/apps/dashboard/src/components/common/{GlassPanel.tsx => LinearBorderPanel.tsx} (100%) create mode 100644 be/apps/dashboard/src/modules/site-settings/components/CustomDomainCard.tsx create mode 100644 be/apps/dashboard/src/modules/site-settings/components/DomainBadge.tsx create mode 100644 be/apps/dashboard/src/modules/site-settings/components/DomainListItem.tsx create mode 100644 be/apps/dashboard/src/pages/(main)/settings/domain.tsx create mode 100644 be/packages/db/migrations/0005_flawless_wild_pack.sql create mode 100644 be/packages/db/migrations/meta/0005_snapshot.json create mode 100644 be/session diff --git a/be/apps/core/src/middlewares/cors.middleware.ts b/be/apps/core/src/middlewares/cors.middleware.ts index db6710fa..8ba4024b 100644 --- a/be/apps/core/src/middlewares/cors.middleware.ts +++ b/be/apps/core/src/middlewares/cors.middleware.ts @@ -4,7 +4,7 @@ import type { Context, Next } from 'hono' import { cors } from 'hono/cors' import { injectable } from 'tsyringe' -@Middleware() +@Middleware({ priority: -2 }) @injectable() export class CorsMiddleware implements HttpMiddleware { private readonly corsMiddleware = cors({ @@ -13,7 +13,7 @@ export class CorsMiddleware implements HttpMiddleware { }, credentials: true, allowMethods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], - allowHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'], + allowHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-Lang'], }) async use(context: Context, next: Next) { diff --git a/be/apps/core/src/modules/platform/auth/auth.provider.ts b/be/apps/core/src/modules/platform/auth/auth.provider.ts index 3d3a6b8c..c5cb1787 100644 --- a/be/apps/core/src/modules/platform/auth/auth.provider.ts +++ b/be/apps/core/src/modules/platform/auth/auth.provider.ts @@ -192,6 +192,20 @@ export class AuthProvider implements OnModuleInit { return `${normalizedBase}${basePath}${query ? `?${query}` : ''}` } + private async buildTrustedOrigins(): Promise { + if (env.NODE_ENV !== 'production') { + return ['http://*.localhost:*', 'https://*.localhost:*', 'http://localhost:*', 'https://localhost:*'] + } + + const settings = await this.systemSettings.getSettings() + return [ + `https://*.${settings.baseDomain}`, + `http://*.${settings.baseDomain}`, + `https://${settings.baseDomain}`, + `http://${settings.baseDomain}`, + ] + } + private async createAuthForEndpoint( tenantSlug: string | null, options: AuthModuleOptions, @@ -217,6 +231,7 @@ export class AuthProvider implements OnModuleInit { }), socialProviders: socialProviders as any, emailAndPassword: { enabled: true }, + trustedOrigins: await this.buildTrustedOrigins(), session: { freshAge: 0, }, diff --git a/be/apps/core/src/modules/platform/tenant/tenant-context-resolver.service.ts b/be/apps/core/src/modules/platform/tenant/tenant-context-resolver.service.ts index 4b041cba..a9f7fda2 100644 --- a/be/apps/core/src/modules/platform/tenant/tenant-context-resolver.service.ts +++ b/be/apps/core/src/modules/platform/tenant/tenant-context-resolver.service.ts @@ -10,6 +10,7 @@ import { injectable } from 'tsyringe' import { PLACEHOLDER_TENANT_SLUG, ROOT_TENANT_SLUG } from './tenant.constants' import { TenantService } from './tenant.service' import type { TenantAggregate, TenantContext } from './tenant.types' +import { TenantDomainService } from './tenant-domain.service' import { extractTenantSlugFromHost } from './tenant-host.utils' const ROOT_TENANT_PATH_PREFIXES = [ @@ -30,6 +31,7 @@ export class TenantContextResolver { constructor( private readonly tenantService: TenantService, + private readonly tenantDomainService: TenantDomainService, private readonly appState: AppStateService, private readonly systemSettingService: SystemSettingService, ) {} @@ -56,7 +58,23 @@ export class TenantContextResolver { this.log.debug(`Forwarded host: ${forwardedHost}, Host header: ${hostHeader}, Origin: ${origin}, Host: ${host}`) const baseDomain = await this.getBaseDomain() - let derivedSlug = host ? (extractTenantSlugFromHost(host, baseDomain) ?? undefined) : undefined + let derivedSlug: string | undefined + let tenantContext: TenantContext | null = null + + if (host) { + const domainMatch = await this.tenantDomainService.resolveTenantByDomain(host) + if (domainMatch) { + tenantContext = this.asTenantContext(domainMatch, false, domainMatch.tenant.slug) + derivedSlug = domainMatch.tenant.slug + this.log.verbose( + `Resolved tenant by custom domain for request ${context.req.method} ${context.req.path} (host=${host})`, + ) + } + } + + if (!derivedSlug) { + derivedSlug = host ? (extractTenantSlugFromHost(host, baseDomain) ?? undefined) : undefined + } if (!derivedSlug && this.isRootTenantPath(context.req.path)) { derivedSlug = ROOT_TENANT_SLUG } @@ -66,8 +84,7 @@ export class TenantContextResolver { `Resolve tenant for request ${context.req.method} ${context.req.path} (host=${host ?? 'n/a'}, slug=${derivedSlug ?? 'n/a'})`, ) - let tenantContext: TenantContext | null = null - if (derivedSlug) { + if (!tenantContext && derivedSlug) { tenantContext = await this.tenantService.resolve( { slug: derivedSlug, @@ -132,7 +149,17 @@ export class TenantContextResolver { return null } - return source.trim().toLowerCase() + const value = source.split(',', 1)[0]?.trim() + if (!value) { + return null + } + + const withoutProtocol = value.replace(/^https?:\/\//, '') + const [hostname] = withoutProtocol.split('/', 1) + const [hostWithoutPort] = hostname.split(':', 1) + + const normalized = hostWithoutPort.trim().toLowerCase() + return normalized.length > 0 ? normalized : null } private extractHostFromOrigin(origin: string | null | undefined): string | null { diff --git a/be/apps/core/src/modules/platform/tenant/tenant-domain.repository.ts b/be/apps/core/src/modules/platform/tenant/tenant-domain.repository.ts new file mode 100644 index 00000000..2151e73c --- /dev/null +++ b/be/apps/core/src/modules/platform/tenant/tenant-domain.repository.ts @@ -0,0 +1,126 @@ +import { tenantDomains, tenants } from '@afilmory/db' +import { DbAccessor } from 'core/database/database.provider' +import { BizException, ErrorCode } from 'core/errors' +import { and, desc, eq } from 'drizzle-orm' +import { injectable } from 'tsyringe' + +import type { TenantDomainAggregate, TenantDomainRecord } from './tenant.types' + +@injectable() +export class TenantDomainRepository { + constructor(private readonly dbAccessor: DbAccessor) {} + + async findActiveByDomain(domain: string): Promise { + const db = this.dbAccessor.get() + const [row] = await db + .select({ + tenant: tenants, + domain: tenantDomains, + }) + .from(tenantDomains) + .innerJoin(tenants, eq(tenantDomains.tenantId, tenants.id)) + .where(and(eq(tenantDomains.domain, domain), eq(tenantDomains.status, 'verified'))) + .limit(1) + + if (!row) { + return null + } + return { tenant: row.tenant, domain: row.domain } + } + + async findByDomain(domain: string): Promise { + const db = this.dbAccessor.get() + const [row] = await db + .select({ + tenant: tenants, + domain: tenantDomains, + }) + .from(tenantDomains) + .innerJoin(tenants, eq(tenantDomains.tenantId, tenants.id)) + .where(eq(tenantDomains.domain, domain)) + .limit(1) + + if (!row) { + return null + } + return { tenant: row.tenant, domain: row.domain } + } + + async findById(id: string): Promise { + const db = this.dbAccessor.get() + const [row] = await db + .select({ + tenant: tenants, + domain: tenantDomains, + }) + .from(tenantDomains) + .innerJoin(tenants, eq(tenantDomains.tenantId, tenants.id)) + .where(eq(tenantDomains.id, id)) + .limit(1) + + if (!row) { + return null + } + + return { + tenant: row.tenant, + domain: row.domain, + } + } + + async listByTenant(tenantId: string): Promise { + const db = this.dbAccessor.get() + return await db + .select() + .from(tenantDomains) + .where(eq(tenantDomains.tenantId, tenantId)) + .orderBy(desc(tenantDomains.createdAt)) + } + + async createDomain(payload: { + tenantId: string + domain: string + verificationToken: string + }): Promise { + const db = this.dbAccessor.get() + await db.insert(tenantDomains).values({ + tenantId: payload.tenantId, + domain: payload.domain, + status: 'pending', + verificationToken: payload.verificationToken, + }) + + const aggregate = await this.findByDomain(payload.domain) + if (!aggregate) { + throw new BizException(ErrorCode.COMMON_INTERNAL_SERVER_ERROR, { message: 'Failed to create tenant domain' }) + } + + return aggregate + } + + async updateDomain( + id: string, + patch: Partial>, + ): Promise { + const db = this.dbAccessor.get() + await db + .update(tenantDomains) + .set({ + ...patch, + updatedAt: new Date().toISOString(), + }) + .where(eq(tenantDomains.id, id)) + + const aggregate = await this.findById(id) + if (!aggregate) { + throw new BizException(ErrorCode.COMMON_INTERNAL_SERVER_ERROR, { message: 'Failed to update tenant domain' }) + } + + return aggregate + } + + async deleteDomain(id: string): Promise { + const db = this.dbAccessor.get() + await db.delete(tenantDomains).where(eq(tenantDomains.id, id)) + } +} diff --git a/be/apps/core/src/modules/platform/tenant/tenant-domain.service.ts b/be/apps/core/src/modules/platform/tenant/tenant-domain.service.ts new file mode 100644 index 00000000..fc918bfa --- /dev/null +++ b/be/apps/core/src/modules/platform/tenant/tenant-domain.service.ts @@ -0,0 +1,196 @@ +import { randomBytes } from 'node:crypto' +import { promises as dns } from 'node:dns' + +import { DEFAULT_BASE_DOMAIN } from '@afilmory/utils' +import { BizException, ErrorCode } from 'core/errors' +import { logger } from 'core/helpers/logger.helper' +import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service' +import { requireTenantContext } from 'core/modules/platform/tenant/tenant.context' +import { injectable } from 'tsyringe' + +import { TenantService } from './tenant.service' +import type { TenantDomainAggregate, TenantDomainRecord } from './tenant.types' +import { TenantDomainRepository } from './tenant-domain.repository' + +@injectable() +export class TenantDomainService { + private readonly log = logger.extend('TenantDomainService') + + constructor( + private readonly repository: TenantDomainRepository, + private readonly tenantService: TenantService, + private readonly systemSettings: SystemSettingService, + ) {} + + async resolveTenantByDomain(host: string): Promise { + const normalized = this.normalizeDomain(host) + if (!normalized) { + return null + } + + const aggregate = await this.repository.findActiveByDomain(normalized) + if (!aggregate) { + return null + } + + this.tenantService.ensureTenantIsActive(aggregate.tenant) + return aggregate + } + + async listDomainsForTenant(): Promise { + const tenantContext = requireTenantContext() + return await this.repository.listByTenant(tenantContext.tenant.id) + } + + async requestDomain(domain: string): Promise { + const tenantContext = requireTenantContext() + const normalized = this.normalizeDomain(domain) + if (!normalized) { + throw new BizException(ErrorCode.COMMON_VALIDATION, { message: '域名不能为空' }) + } + + const baseDomain = await this.getBaseDomain() + if (normalized === baseDomain || normalized.endsWith(`.${baseDomain}`)) { + throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '无需绑定主域名或其子域名' }) + } + + const existing = await this.repository.findByDomain(normalized) + if (existing && existing.tenant.id !== tenantContext.tenant.id) { + throw new BizException(ErrorCode.COMMON_CONFLICT, { message: '该域名已被其他空间绑定' }) + } + + if (existing) { + if (existing.domain.status === 'verified') { + return existing + } + const verificationToken = this.generateVerificationToken() + return await this.repository.updateDomain(existing.domain.id, { + status: 'pending', + verificationToken, + verifiedAt: null, + }) + } + + const verificationToken = this.generateVerificationToken() + return await this.repository.createDomain({ + tenantId: tenantContext.tenant.id, + domain: normalized, + verificationToken, + }) + } + + async verifyDomain(domainId: string): Promise { + const tenantContext = requireTenantContext() + const aggregate = await this.repository.findById(domainId) + if (!aggregate) { + throw new BizException(ErrorCode.COMMON_NOT_FOUND, { message: '未找到该域名记录' }) + } + if (aggregate.tenant.id !== tenantContext.tenant.id) { + throw new BizException(ErrorCode.COMMON_FORBIDDEN, { message: '无法操作其他空间的域名' }) + } + + if (aggregate.domain.status === 'verified') { + return aggregate + } + + this.tenantService.ensureTenantIsActive(aggregate.tenant) + + const verification = await this.performDnsVerification(aggregate.domain) + if (!verification.ok) { + throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { + message: verification.reason ?? '域名未正确指向,请稍后再试', + }) + } + + return await this.repository.updateDomain(aggregate.domain.id, { + status: 'verified', + verifiedAt: new Date().toISOString(), + }) + } + + async deleteDomain(domainId: string): Promise { + const tenantContext = requireTenantContext() + const aggregate = await this.repository.findById(domainId) + if (!aggregate) { + return + } + if (aggregate.tenant.id !== tenantContext.tenant.id) { + throw new BizException(ErrorCode.COMMON_FORBIDDEN, { message: '无法操作其他空间的域名' }) + } + await this.repository.deleteDomain(domainId) + } + + private normalizeDomain(value?: string | null): string | null { + if (!value) { + return null + } + const trimmed = value.trim().toLowerCase() + if (!trimmed) { + return null + } + + const withoutProtocol = trimmed.replace(/^https?:\/\//, '') + const [hostname] = withoutProtocol.split('/', 1) + const [hostWithoutPort] = hostname.split(':', 1) + const normalized = hostWithoutPort.endsWith('.') ? hostWithoutPort.slice(0, -1) : hostWithoutPort + + return normalized.length > 0 ? normalized : null + } + + private generateVerificationToken(): string { + return randomBytes(16).toString('hex') + } + + private async performDnsVerification(domain: TenantDomainRecord): Promise<{ ok: boolean; reason?: string }> { + const baseDomain = await this.getBaseDomain() + + const [cnameTargets, txtRecords] = await Promise.all([ + this.resolveCname(domain.domain), + this.resolveTxt(domain.domain), + ]) + + const normalizedBase = baseDomain.toLowerCase() + const cnameMatches = cnameTargets.some((target) => this.matchesBaseDomain(target, normalizedBase)) + const txtMatches = + domain.verificationToken?.length > 0 && txtRecords.some((entries) => entries.includes(domain.verificationToken)) + + if (cnameMatches || txtMatches) { + return { ok: true } + } + + return { + ok: false, + reason: `未检测到指向 ${normalizedBase} 的 CNAME 或包含验证 token 的 TXT 记录`, + } + } + + private async resolveCname(domain: string): Promise { + try { + return await dns.resolveCname(domain) + } catch (error) { + this.log.debug(`resolveCname failed for ${domain}`, error) + return [] + } + } + + private async resolveTxt(domain: string): Promise { + try { + return await dns.resolveTxt(domain) + } catch (error) { + this.log.debug(`resolveTxt failed for ${domain}`, error) + return [] + } + } + + private matchesBaseDomain(target: string, baseDomain: string): boolean { + const normalizedTarget = target.trim().toLowerCase().replace(/\.$/, '') + const normalizedBase = baseDomain.trim().toLowerCase().replace(/\.$/, '') + + return normalizedTarget === normalizedBase || normalizedTarget.endsWith(`.${normalizedBase}`) + } + + private async getBaseDomain(): Promise { + const settings = await this.systemSettings.getSettings() + return settings.baseDomain || DEFAULT_BASE_DOMAIN + } +} diff --git a/be/apps/core/src/modules/platform/tenant/tenant.controller.ts b/be/apps/core/src/modules/platform/tenant/tenant.controller.ts index e8dd5108..c2b895c0 100644 --- a/be/apps/core/src/modules/platform/tenant/tenant.controller.ts +++ b/be/apps/core/src/modules/platform/tenant/tenant.controller.ts @@ -1,12 +1,14 @@ -import { Body, Controller, createZodSchemaDto, Post } from '@afilmory/framework' +import { Body, Controller, createZodSchemaDto, Delete, Get, Param, Post } from '@afilmory/framework' import { isTenantSlugReserved } from '@afilmory/utils' import { AllowPlaceholderTenant } from 'core/decorators/allow-placeholder.decorator' import { SkipTenantGuard } from 'core/decorators/skip-tenant.decorator' import { BizException, ErrorCode } from 'core/errors' +import { Roles } from 'core/guards/roles.decorator' import { SystemSettingService } from 'core/modules/configuration/system-setting/system-setting.service' import { z } from 'zod' import { TenantService } from './tenant.service' +import { TenantDomainService } from './tenant-domain.service' const TENANT_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i @@ -41,11 +43,22 @@ const checkTenantSlugSchema = z.object({ class CheckTenantSlugDto extends createZodSchemaDto(checkTenantSlugSchema) {} +const requestDomainSchema = z.object({ + domain: z + .string() + .trim() + .min(1, { message: '域名不能为空' }) + .regex(/^[a-z0-9.-]+$/i, { message: '域名只能包含字母、数字、连字符和点' }), +}) + +class RequestTenantDomainDto extends createZodSchemaDto(requestDomainSchema) {} + @Controller('tenant') export class TenantController { constructor( private readonly tenantService: TenantService, private readonly systemSettings: SystemSettingService, + private readonly tenantDomainService: TenantDomainService, ) {} @AllowPlaceholderTenant() @@ -91,4 +104,32 @@ export class TenantController { } return 'https' } + + @Get('/domains') + @Roles('admin') + async listDomains() { + const domains = await this.tenantDomainService.listDomainsForTenant() + return { domains } + } + + @Post('/domains') + @Roles('admin') + async requestDomain(@Body() body: RequestTenantDomainDto) { + const aggregate = await this.tenantDomainService.requestDomain(body.domain) + return { domain: aggregate.domain } + } + + @Post('/domains/:domainId/verify') + @Roles('admin') + async verifyDomain(@Param('domainId') domainId: string) { + const aggregate = await this.tenantDomainService.verifyDomain(domainId) + return { domain: aggregate.domain } + } + + @Delete('/domains/:domainId') + @Roles('admin') + async deleteDomain(@Param('domainId') domainId: string) { + await this.tenantDomainService.deleteDomain(domainId) + return { deleted: true } + } } diff --git a/be/apps/core/src/modules/platform/tenant/tenant.module.ts b/be/apps/core/src/modules/platform/tenant/tenant.module.ts index 898bd069..c85d34ce 100644 --- a/be/apps/core/src/modules/platform/tenant/tenant.module.ts +++ b/be/apps/core/src/modules/platform/tenant/tenant.module.ts @@ -9,10 +9,12 @@ import { TenantController } from './tenant.controller' import { TenantRepository } from './tenant.repository' import { TenantService } from './tenant.service' import { TenantContextResolver } from './tenant-context-resolver.service' +import { TenantDomainRepository } from './tenant-domain.repository' +import { TenantDomainService } from './tenant-domain.service' @Module({ imports: [DatabaseModule, AppStateModule, SystemSettingModule], controllers: [TenantController], - providers: [TenantRepository, TenantService, TenantContextResolver], + providers: [TenantRepository, TenantDomainRepository, TenantService, TenantDomainService, TenantContextResolver], }) export class TenantModule {} 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 e9bffe9d..4d9ac586 100644 --- a/be/apps/core/src/modules/platform/tenant/tenant.service.ts +++ b/be/apps/core/src/modules/platform/tenant/tenant.service.ts @@ -153,7 +153,7 @@ export class TenantService { return existing === null } - private ensureTenantIsActive(tenant: TenantAggregate['tenant']): void { + ensureTenantIsActive(tenant: TenantAggregate['tenant']): void { if (tenant.banned) { throw new BizException(ErrorCode.TENANT_BANNED) } diff --git a/be/apps/core/src/modules/platform/tenant/tenant.types.ts b/be/apps/core/src/modules/platform/tenant/tenant.types.ts index 89702241..56022ed0 100644 --- a/be/apps/core/src/modules/platform/tenant/tenant.types.ts +++ b/be/apps/core/src/modules/platform/tenant/tenant.types.ts @@ -1,11 +1,16 @@ -import type { tenants } from '@afilmory/db' +import type { tenantDomains, tenants } from '@afilmory/db' export type TenantRecord = typeof tenants.$inferSelect +export type TenantDomainRecord = typeof tenantDomains.$inferSelect export interface TenantAggregate { tenant: TenantRecord } +export interface TenantDomainAggregate extends TenantAggregate { + domain: TenantDomainRecord +} + export interface TenantContext extends TenantAggregate { readonly isPlaceholder?: boolean readonly requestedSlug?: string | null diff --git a/be/apps/dashboard/.env.example b/be/apps/dashboard/.env.example index e6066689..8e5cea86 100644 --- a/be/apps/dashboard/.env.example +++ b/be/apps/dashboard/.env.example @@ -1,2 +1 @@ - -CORE_API_URL=http://0.0.0.0:1841 \ No newline at end of file +VITE_APP_CORE_API_URL=http://localhost:1841/api \ No newline at end of file diff --git a/be/apps/dashboard/src/components/common/GlassPanel.tsx b/be/apps/dashboard/src/components/common/LinearBorderPanel.tsx similarity index 100% rename from be/apps/dashboard/src/components/common/GlassPanel.tsx rename to be/apps/dashboard/src/components/common/LinearBorderPanel.tsx diff --git a/be/apps/dashboard/src/lib/api-client.ts b/be/apps/dashboard/src/lib/api-client.ts index 57109a3b..07d631d7 100644 --- a/be/apps/dashboard/src/lib/api-client.ts +++ b/be/apps/dashboard/src/lib/api-client.ts @@ -3,7 +3,7 @@ import { $fetch } from 'ofetch' import { getAccessDenied, setAccessDenied } from '~/atoms/access-denied' import { withLanguageHeader } from '~/lib/request-language' -export const coreApiBaseURL = import.meta.env.VITE_APP_API_BASE?.replace(/\/$/, '') || '/api' +export const coreApiBaseURL = import.meta.env.VITE_APP_CORE_API_URL || '/api' export const coreApi = $fetch.create({ baseURL: coreApiBaseURL, diff --git a/be/apps/dashboard/src/modules/analytics/components/AnalyticsPage.tsx b/be/apps/dashboard/src/modules/analytics/components/AnalyticsPage.tsx index c342e6f7..deb74651 100644 --- a/be/apps/dashboard/src/modules/analytics/components/AnalyticsPage.tsx +++ b/be/apps/dashboard/src/modules/analytics/components/AnalyticsPage.tsx @@ -4,7 +4,7 @@ import type { ReactNode } from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { LinearBorderPanel } from '~/components/common/GlassPanel' +import { LinearBorderPanel } from '~/components/common/LinearBorderPanel' import { MainPageLayout } from '~/components/layouts/MainPageLayout' import { useDashboardAnalyticsQuery } from '../hooks' diff --git a/be/apps/dashboard/src/modules/auth/auth-client.ts b/be/apps/dashboard/src/modules/auth/auth-client.ts index a5dde968..fadc0a80 100644 --- a/be/apps/dashboard/src/modules/auth/auth-client.ts +++ b/be/apps/dashboard/src/modules/auth/auth-client.ts @@ -2,7 +2,7 @@ import { creemClient } from '@creem_io/better-auth/client' import { createCreemAuthClient } from '@creem_io/better-auth/create-creem-auth-client' import { FetchError } from 'ofetch' -const apiBase = import.meta.env.VITE_APP_API_BASE?.replace(/\/$/, '') || '/api' +const apiBase = import.meta.env.VITE_APP_CORE_API_URL?.replace(/\/$/, '') || '/api' const authBase = resolveUrl(`${apiBase}/auth`) diff --git a/be/apps/dashboard/src/modules/auth/components/SocialConnectionSettings.tsx b/be/apps/dashboard/src/modules/auth/components/SocialConnectionSettings.tsx index 632b6944..bb1cc400 100644 --- a/be/apps/dashboard/src/modules/auth/components/SocialConnectionSettings.tsx +++ b/be/apps/dashboard/src/modules/auth/components/SocialConnectionSettings.tsx @@ -4,7 +4,7 @@ import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { LinearBorderPanel } from '~/components/common/GlassPanel' +import { LinearBorderPanel } from '~/components/common/LinearBorderPanel' import { getRequestErrorMessage } from '~/lib/errors' import type { SocialAccountRecord } from '../api/socialAccounts' diff --git a/be/apps/dashboard/src/modules/builder-settings/components/BuilderSettingsForm.tsx b/be/apps/dashboard/src/modules/builder-settings/components/BuilderSettingsForm.tsx index 0c73bdf9..8b02506f 100644 --- a/be/apps/dashboard/src/modules/builder-settings/components/BuilderSettingsForm.tsx +++ b/be/apps/dashboard/src/modules/builder-settings/components/BuilderSettingsForm.tsx @@ -4,7 +4,7 @@ import { m } from 'motion/react' import { startTransition, useCallback, useEffect, useId, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { LinearBorderPanel } from '~/components/common/GlassPanel' +import { LinearBorderPanel } from '~/components/common/LinearBorderPanel' import { MainPageLayout, useMainPageLayout } from '~/components/layouts/MainPageLayout' import { SchemaFormRenderer } from '../../schema-form/SchemaFormRenderer' diff --git a/be/apps/dashboard/src/modules/dashboard/components/DashboardOverview.tsx b/be/apps/dashboard/src/modules/dashboard/components/DashboardOverview.tsx index 279e409e..0fc0687d 100644 --- a/be/apps/dashboard/src/modules/dashboard/components/DashboardOverview.tsx +++ b/be/apps/dashboard/src/modules/dashboard/components/DashboardOverview.tsx @@ -5,7 +5,7 @@ import { m } from 'motion/react' import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { LinearBorderPanel } from '~/components/common/GlassPanel' +import { LinearBorderPanel } from '~/components/common/LinearBorderPanel' import { MainPageLayout } from '~/components/layouts/MainPageLayout' import { useDashboardOverviewQuery } from '../hooks' diff --git a/be/apps/dashboard/src/modules/data-management/components/DataManagementPanel.tsx b/be/apps/dashboard/src/modules/data-management/components/DataManagementPanel.tsx index b067f11e..f4142945 100644 --- a/be/apps/dashboard/src/modules/data-management/components/DataManagementPanel.tsx +++ b/be/apps/dashboard/src/modules/data-management/components/DataManagementPanel.tsx @@ -4,7 +4,7 @@ import { DynamicIcon } from 'lucide-react/dynamic' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { LinearBorderPanel } from '~/components/common/GlassPanel' +import { LinearBorderPanel } from '~/components/common/LinearBorderPanel' import { usePhotoAssetSummaryQuery } from '~/modules/photos/hooks' import { useDeleteTenantAccountMutation, useTruncatePhotoAssetsMutation } from '../hooks' diff --git a/be/apps/dashboard/src/modules/photos/components/library/PhotoLibraryGrid.tsx b/be/apps/dashboard/src/modules/photos/components/library/PhotoLibraryGrid.tsx index 9cda34ee..ad669d16 100644 --- a/be/apps/dashboard/src/modules/photos/components/library/PhotoLibraryGrid.tsx +++ b/be/apps/dashboard/src/modules/photos/components/library/PhotoLibraryGrid.tsx @@ -17,7 +17,7 @@ import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/shallow' import { viewportAtom } from '~/atoms/viewport' -import { LinearBorderPanel } from '~/components/common/GlassPanel' +import { LinearBorderPanel } from '~/components/common/LinearBorderPanel' import { stopPropagation } from '~/lib/dom' import type { PhotoAssetListItem } from '../../types' diff --git a/be/apps/dashboard/src/modules/photos/components/library/photo-upload/ProcessingPanel.tsx b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/ProcessingPanel.tsx index 1b8e9d3e..7ee33457 100644 --- a/be/apps/dashboard/src/modules/photos/components/library/photo-upload/ProcessingPanel.tsx +++ b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/ProcessingPanel.tsx @@ -1,6 +1,6 @@ import { ScrollArea } from '@afilmory/ui' -import { LinearBorderPanel } from '~/components/common/GlassPanel' +import { LinearBorderPanel } from '~/components/common/LinearBorderPanel' import { STAGE_CONFIG, STAGE_ORDER, SUMMARY_FIELDS } from './constants' import type { ProcessingLogEntry, ProcessingState } from './types' diff --git a/be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/ErrorStep.tsx b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/ErrorStep.tsx index cdbf7827..90e09092 100644 --- a/be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/ErrorStep.tsx +++ b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/ErrorStep.tsx @@ -1,7 +1,7 @@ import { Button } from '@afilmory/ui' import { useShallow } from 'zustand/shallow' -import { LinearBorderPanel } from '~/components/common/GlassPanel' +import { LinearBorderPanel } from '~/components/common/LinearBorderPanel' import { ProcessingPanel } from '../ProcessingPanel' import { usePhotoUploadStore } from '../store' diff --git a/be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/ReviewStep.tsx b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/ReviewStep.tsx index a055d79b..2498d963 100644 --- a/be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/ReviewStep.tsx +++ b/be/apps/dashboard/src/modules/photos/components/library/photo-upload/steps/ReviewStep.tsx @@ -2,7 +2,7 @@ import { Button } from '@afilmory/ui' import { useMemo } from 'react' import { useShallow } from 'zustand/shallow' -import { LinearBorderPanel } from '~/components/common/GlassPanel' +import { LinearBorderPanel } from '~/components/common/LinearBorderPanel' import { AutoSelect } from '../AutoSelect' import { usePhotoUploadStore } from '../store' diff --git a/be/apps/dashboard/src/modules/photos/components/usage/PhotoUsagePanel.tsx b/be/apps/dashboard/src/modules/photos/components/usage/PhotoUsagePanel.tsx index 6b1801a0..be6403f1 100644 --- a/be/apps/dashboard/src/modules/photos/components/usage/PhotoUsagePanel.tsx +++ b/be/apps/dashboard/src/modules/photos/components/usage/PhotoUsagePanel.tsx @@ -2,7 +2,7 @@ import { Button } from '@afilmory/ui' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { LinearBorderPanel } from '~/components/common/GlassPanel' +import { LinearBorderPanel } from '~/components/common/LinearBorderPanel' import { BILLING_USAGE_EVENT_CONFIG, getUsageEventDescription, getUsageEventLabel } from '../../constants' import type { BillingUsageEvent, BillingUsageOverview } from '../../types' diff --git a/be/apps/dashboard/src/modules/schema-form/SchemaFormRenderer.tsx b/be/apps/dashboard/src/modules/schema-form/SchemaFormRenderer.tsx index 06149961..f722a96e 100644 --- a/be/apps/dashboard/src/modules/schema-form/SchemaFormRenderer.tsx +++ b/be/apps/dashboard/src/modules/schema-form/SchemaFormRenderer.tsx @@ -17,7 +17,7 @@ import type { ReactNode } from 'react' import { Fragment, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { LinearBorderPanel } from '../../components/common/GlassPanel' +import { LinearBorderPanel } from '../../components/common/LinearBorderPanel' import type { SchemaFormState, SchemaFormValue, diff --git a/be/apps/dashboard/src/modules/settings/components/SettingsNavigation.tsx b/be/apps/dashboard/src/modules/settings/components/SettingsNavigation.tsx index feadcace..78fa0c4b 100644 --- a/be/apps/dashboard/src/modules/settings/components/SettingsNavigation.tsx +++ b/be/apps/dashboard/src/modules/settings/components/SettingsNavigation.tsx @@ -7,6 +7,12 @@ const SETTINGS_TABS = [ path: '/settings/site', end: true, }, + { + id: 'domain', + labelKey: 'settings.nav.domain', + path: '/settings/domain', + end: true, + }, { id: 'user', labelKey: 'settings.nav.user', diff --git a/be/apps/dashboard/src/modules/site-settings/api.ts b/be/apps/dashboard/src/modules/site-settings/api.ts index 7347e30b..96246caf 100644 --- a/be/apps/dashboard/src/modules/site-settings/api.ts +++ b/be/apps/dashboard/src/modules/site-settings/api.ts @@ -5,6 +5,7 @@ import type { SiteAuthorProfile, SiteSettingEntryInput, SiteSettingUiSchemaResponse, + TenantDomain, UpdateSiteAuthorPayload, } from './types' @@ -31,3 +32,27 @@ export async function updateSiteAuthorProfile(payload: UpdateSiteAuthorPayload) body: payload, }) } + +export async function listTenantDomains() { + const result = await coreApi<{ domains: TenantDomain[] }>('/tenant/domains') + return camelCaseKeys(result) as { domains: TenantDomain[] } +} + +export async function requestTenantDomain(domain: string) { + const result = await coreApi<{ domain: TenantDomain }>('/tenant/domains', { + method: 'POST', + body: { domain }, + }) + return camelCaseKeys(result) as { domain: TenantDomain } +} + +export async function verifyTenantDomain(domainId: string) { + const result = await coreApi<{ domain: TenantDomain }>(`/tenant/domains/${domainId}/verify`, { + method: 'POST', + }) + return camelCaseKeys(result) as { domain: TenantDomain } +} + +export async function deleteTenantDomain(domainId: string) { + return await coreApi<{ deleted: boolean }>(`/tenant/domains/${domainId}`, { method: 'DELETE' }) +} diff --git a/be/apps/dashboard/src/modules/site-settings/components/CustomDomainCard.tsx b/be/apps/dashboard/src/modules/site-settings/components/CustomDomainCard.tsx new file mode 100644 index 00000000..71a09902 --- /dev/null +++ b/be/apps/dashboard/src/modules/site-settings/components/CustomDomainCard.tsx @@ -0,0 +1,162 @@ +import { Button, FormHelperText, Input } from '@afilmory/ui' +import { Spring } from '@afilmory/utils' +import { Loader2 } from 'lucide-react' +import { m } from 'motion/react' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +import { LinearBorderPanel } from '~/components/common/LinearBorderPanel' +import { resolveBaseDomain } from '~/modules/auth/utils/domain' + +import { + useDeleteTenantDomainMutation, + useRequestTenantDomainMutation, + useTenantDomainsQuery, + useVerifyTenantDomainMutation, +} from '../hooks' +import { DomainListItem } from './DomainListItem' + +function normalizeHostname(): string { + if (typeof window === 'undefined') { + return '' + } + const { hostname } = window.location + return hostname ?? '' +} + +function buildVerificationInstructions(normalizedBase = 'your-domain.com') { + return [ + { + titleKey: 'settings.domain.steps.cname.title', + descriptionKey: 'settings.domain.steps.cname.desc', + meta: normalizedBase, + }, + { + titleKey: 'settings.domain.steps.txt.title', + descriptionKey: 'settings.domain.steps.txt.desc', + }, + { + titleKey: 'settings.domain.steps.verify.title', + descriptionKey: 'settings.domain.steps.verify.desc', + }, + ] satisfies { + titleKey: I18nKeys + descriptionKey: I18nKeys + meta?: string + }[] +} + +export function CustomDomainCard() { + const { t } = useTranslation() + const [domainInput, setDomainInput] = useState('') + const { data: domains = [], isLoading } = useTenantDomainsQuery() + const requestDomainMutation = useRequestTenantDomainMutation() + const verifyMutation = useVerifyTenantDomainMutation() + const deleteMutation = useDeleteTenantDomainMutation() + + const baseDomain = useMemo(() => resolveBaseDomain(normalizeHostname()), []) + const steps = useMemo(() => buildVerificationInstructions(baseDomain), [baseDomain]) + + const handleRequest = async () => { + if (!domainInput.trim()) { + toast.error(t('settings.domain.toast.input-required')) + return + } + requestDomainMutation.mutate(domainInput.trim()) + } + + const pendingDomain = domains.find((item) => item.status === 'pending') + + return ( + +
+
+

{t('settings.domain.title')}

+

{t('settings.domain.description', { base: baseDomain })}

+
+ +
+
+
+ + setDomainInput(event.target.value)} + placeholder={t('settings.domain.input.placeholder')} + disabled={requestDomainMutation.isPending} + /> +
+ + + {t('settings.domain.input.helper', { base: baseDomain })} + +
+
+ +

+ {t('settings.domain.steps.title')} +

+
+ {steps.map((step, index) => ( + + + {index + 1} +
+

{t(step.titleKey)}

+

+ {t(step.descriptionKey, { base: baseDomain })} + {step.meta ? {step.meta} : null} +

+
+
+
+ ))} +
+
+ +
+
+

{t('settings.domain.bound-list.title')}

+ {isLoading && } +
+ {domains.length === 0 ? ( +

{t('settings.domain.bound-list.empty')}

+ ) : ( +
+ {domains.map((domain) => ( + + ))} +
+ )} +
+
+ + {pendingDomain ? ( + +

{t('settings.domain.banner.pending', { domain: pendingDomain.domain })}

+
+ ) : null} +
+
+ ) +} diff --git a/be/apps/dashboard/src/modules/site-settings/components/DomainBadge.tsx b/be/apps/dashboard/src/modules/site-settings/components/DomainBadge.tsx new file mode 100644 index 00000000..4399c495 --- /dev/null +++ b/be/apps/dashboard/src/modules/site-settings/components/DomainBadge.tsx @@ -0,0 +1,22 @@ +import { CheckCircle2, CircleDashed, Undo2 } from 'lucide-react' +import { useTranslation } from 'react-i18next' + +import type { TenantDomain } from '../types' + +const STATUS_ICON_MAP: Record = { + verified: , + pending: , + disabled: , +} + +export function DomainBadge({ status }: { status: TenantDomain['status'] }) { + const { t } = useTranslation() + const label = t(`settings.domain.status.${status}`) + const icon = STATUS_ICON_MAP[status] + return ( + + {icon} + {label} + + ) +} diff --git a/be/apps/dashboard/src/modules/site-settings/components/DomainListItem.tsx b/be/apps/dashboard/src/modules/site-settings/components/DomainListItem.tsx new file mode 100644 index 00000000..7896774b --- /dev/null +++ b/be/apps/dashboard/src/modules/site-settings/components/DomainListItem.tsx @@ -0,0 +1,64 @@ +import { Button, FormHelperText } from '@afilmory/ui' +import { Trash2 } from 'lucide-react' +import { useTranslation } from 'react-i18next' + +import { LinearBorderPanel } from '~/components/common/LinearBorderPanel' + +import type { TenantDomain } from '../types' +import { DomainBadge } from './DomainBadge' + +interface DomainListItemProps { + domain: TenantDomain + onVerify: (id: string) => void + onDelete: (id: string) => void + isVerifying: boolean + isDeleting: boolean +} + +export function DomainListItem({ domain, onVerify, onDelete, isVerifying, isDeleting }: DomainListItemProps) { + const { t } = useTranslation() + + return ( + +
+
+
+ {domain.domain} + +
+
+ {domain.status === 'pending' ? ( + + ) : null} + +
+
+ {domain.status === 'pending' ? ( + +
+

+ {t('settings.domain.token.label')} +

+ + {domain.verificationToken} + + + {t('settings.domain.token.helper')} + +
+
+ ) : null} +
+
+ ) +} diff --git a/be/apps/dashboard/src/modules/site-settings/components/SiteSettingsForm.tsx b/be/apps/dashboard/src/modules/site-settings/components/SiteSettingsForm.tsx index 372f2e14..36be7883 100644 --- a/be/apps/dashboard/src/modules/site-settings/components/SiteSettingsForm.tsx +++ b/be/apps/dashboard/src/modules/site-settings/components/SiteSettingsForm.tsx @@ -4,7 +4,7 @@ import { m } from 'motion/react' import { startTransition, useCallback, useEffect, useId, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { LinearBorderPanel } from '~/components/common/GlassPanel' +import { LinearBorderPanel } from '~/components/common/LinearBorderPanel' import { MainPageLayout, useMainPageLayout } from '~/components/layouts/MainPageLayout' import { SchemaFormRenderer } from '../../schema-form/SchemaFormRenderer' diff --git a/be/apps/dashboard/src/modules/site-settings/components/SiteUserProfileForm.tsx b/be/apps/dashboard/src/modules/site-settings/components/SiteUserProfileForm.tsx index 72b5a5b4..d1db8e89 100644 --- a/be/apps/dashboard/src/modules/site-settings/components/SiteUserProfileForm.tsx +++ b/be/apps/dashboard/src/modules/site-settings/components/SiteUserProfileForm.tsx @@ -5,7 +5,7 @@ import { startTransition, useCallback, useEffect, useId, useMemo, useState } fro import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { LinearBorderPanel } from '~/components/common/GlassPanel' +import { LinearBorderPanel } from '~/components/common/LinearBorderPanel' import { MainPageLayout, useMainPageLayout } from '~/components/layouts/MainPageLayout' import { useBlock } from '~/hooks/useBlock' import { getRequestErrorMessage } from '~/lib/errors' diff --git a/be/apps/dashboard/src/modules/site-settings/hooks.ts b/be/apps/dashboard/src/modules/site-settings/hooks.ts index 2e17e940..233582ec 100644 --- a/be/apps/dashboard/src/modules/site-settings/hooks.ts +++ b/be/apps/dashboard/src/modules/site-settings/hooks.ts @@ -1,10 +1,24 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' -import { getSiteAuthorProfile, getSiteSettingUiSchema, updateSiteAuthorProfile, updateSiteSettings } from './api' -import type { SiteSettingEntryInput, UpdateSiteAuthorPayload } from './types' +import { getRequestErrorMessage } from '~/lib/errors' + +import { + deleteTenantDomain, + getSiteAuthorProfile, + getSiteSettingUiSchema, + listTenantDomains, + requestTenantDomain, + updateSiteAuthorProfile, + updateSiteSettings, + verifyTenantDomain, +} from './api' +import type { SiteSettingEntryInput, TenantDomain, UpdateSiteAuthorPayload } from './types' export const SITE_SETTING_UI_SCHEMA_QUERY_KEY = ['site-settings', 'ui-schema'] as const export const SITE_AUTHOR_PROFILE_QUERY_KEY = ['site-settings', 'author-profile'] as const +export const TENANT_DOMAINS_QUERY_KEY = ['tenant', 'domains'] as const export function useSiteSettingUiSchemaQuery() { return useQuery({ @@ -47,3 +61,72 @@ export function useUpdateSiteAuthorProfileMutation() { }, }) } + +export function useTenantDomainsQuery() { + return useQuery({ + queryKey: TENANT_DOMAINS_QUERY_KEY, + queryFn: async () => { + const { domains } = await listTenantDomains() + return domains + }, + }) +} + +export function useRequestTenantDomainMutation() { + const queryClient = useQueryClient() + const { t } = useTranslation() + + return useMutation({ + mutationFn: async (domain: string) => { + const { domain: record } = await requestTenantDomain(domain) + return record satisfies TenantDomain + }, + onSuccess: () => { + toast.success(t('settings.domain.toast.request-success')) + void queryClient.invalidateQueries({ queryKey: TENANT_DOMAINS_QUERY_KEY }) + }, + onError: (error) => { + const description = getRequestErrorMessage(error, t('errors.request.generic')) + toast.error(t('settings.domain.toast.request-failed'), { description }) + }, + }) +} + +export function useVerifyTenantDomainMutation() { + const queryClient = useQueryClient() + const { t } = useTranslation() + + return useMutation({ + mutationFn: async (domainId: string) => { + const { domain } = await verifyTenantDomain(domainId) + return domain satisfies TenantDomain + }, + onSuccess: () => { + toast.success(t('settings.domain.toast.verify-success')) + void queryClient.invalidateQueries({ queryKey: TENANT_DOMAINS_QUERY_KEY }) + }, + onError: (error) => { + const description = getRequestErrorMessage(error, t('errors.request.generic')) + toast.error(t('settings.domain.toast.verify-failed'), { description }) + }, + }) +} + +export function useDeleteTenantDomainMutation() { + const queryClient = useQueryClient() + const { t } = useTranslation() + + return useMutation({ + mutationFn: async (domainId: string) => { + await deleteTenantDomain(domainId) + }, + onSuccess: () => { + toast.success(t('settings.domain.toast.delete-success')) + void queryClient.invalidateQueries({ queryKey: TENANT_DOMAINS_QUERY_KEY }) + }, + onError: (error) => { + const description = getRequestErrorMessage(error, t('errors.request.generic')) + toast.error(t('settings.domain.toast.delete-failed'), { description }) + }, + }) +} diff --git a/be/apps/dashboard/src/modules/site-settings/index.ts b/be/apps/dashboard/src/modules/site-settings/index.ts index 27cd50ef..01c29072 100644 --- a/be/apps/dashboard/src/modules/site-settings/index.ts +++ b/be/apps/dashboard/src/modules/site-settings/index.ts @@ -1,4 +1,5 @@ export * from './api' +export * from './components/CustomDomainCard' export * from './components/SiteSettingsForm' export * from './components/SiteUserProfileForm' export * from './hooks' diff --git a/be/apps/dashboard/src/modules/site-settings/types.ts b/be/apps/dashboard/src/modules/site-settings/types.ts index c721fd06..aea424a7 100644 --- a/be/apps/dashboard/src/modules/site-settings/types.ts +++ b/be/apps/dashboard/src/modules/site-settings/types.ts @@ -29,3 +29,14 @@ export type UpdateSiteAuthorPayload = { username?: string | null avatar?: string | null } + +export type TenantDomain = { + id: string + tenantId: string + domain: string + status: 'pending' | 'verified' | 'disabled' + verificationToken: string + verifiedAt: string | null + createdAt: string + updatedAt: string +} diff --git a/be/apps/dashboard/src/modules/storage-providers/components/StorageProvidersManager.tsx b/be/apps/dashboard/src/modules/storage-providers/components/StorageProvidersManager.tsx index 4b75b03b..2f0b424d 100644 --- a/be/apps/dashboard/src/modules/storage-providers/components/StorageProvidersManager.tsx +++ b/be/apps/dashboard/src/modules/storage-providers/components/StorageProvidersManager.tsx @@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router' import { useSetPhotoSyncAutoRun } from '~/atoms/photo-sync' -import { LinearBorderPanel } from '~/components/common/GlassPanel' +import { LinearBorderPanel } from '~/components/common/LinearBorderPanel' import { MainPageLayout, useMainPageLayout } from '~/components/layouts/MainPageLayout' import { useBlock } from '~/hooks/useBlock' diff --git a/be/apps/dashboard/src/modules/super-admin/components/SuperAdminSettingsForm.tsx b/be/apps/dashboard/src/modules/super-admin/components/SuperAdminSettingsForm.tsx index ea1b631e..0797c650 100644 --- a/be/apps/dashboard/src/modules/super-admin/components/SuperAdminSettingsForm.tsx +++ b/be/apps/dashboard/src/modules/super-admin/components/SuperAdminSettingsForm.tsx @@ -5,7 +5,7 @@ import { m } from 'motion/react' import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { LinearBorderPanel } from '~/components/common/GlassPanel' +import { LinearBorderPanel } from '~/components/common/LinearBorderPanel' import { SchemaFormRenderer } from '../../schema-form/SchemaFormRenderer' import type { SchemaFormState, SchemaFormValue, UiNode } from '../../schema-form/types' 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 3e8731c6..cfde1ff6 100644 --- a/be/apps/dashboard/src/modules/super-admin/components/SuperAdminTenantManager.tsx +++ b/be/apps/dashboard/src/modules/super-admin/components/SuperAdminTenantManager.tsx @@ -6,7 +6,7 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { LinearBorderPanel } from '~/components/common/GlassPanel' +import { LinearBorderPanel } from '~/components/common/LinearBorderPanel' import { BILLING_USAGE_EVENT_CONFIG } from '~/modules/photos/constants' import type { BillingUsageEventType } from '~/modules/photos/types' diff --git a/be/apps/dashboard/src/pages/(main)/plan.tsx b/be/apps/dashboard/src/pages/(main)/plan.tsx index 9b57671a..a72a3f56 100644 --- a/be/apps/dashboard/src/pages/(main)/plan.tsx +++ b/be/apps/dashboard/src/pages/(main)/plan.tsx @@ -5,7 +5,7 @@ import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { LinearBorderPanel } from '~/components/common/GlassPanel' +import { LinearBorderPanel } from '~/components/common/LinearBorderPanel' import { MainPageLayout } from '~/components/layouts/MainPageLayout' import type { SessionResponse } from '~/modules/auth/api/session' import { AUTH_SESSION_QUERY_KEY } from '~/modules/auth/api/session' diff --git a/be/apps/dashboard/src/pages/(main)/settings/domain.tsx b/be/apps/dashboard/src/pages/(main)/settings/domain.tsx new file mode 100644 index 00000000..40ab066d --- /dev/null +++ b/be/apps/dashboard/src/pages/(main)/settings/domain.tsx @@ -0,0 +1,17 @@ +import { useTranslation } from 'react-i18next' + +import { MainPageLayout } from '~/components/layouts/MainPageLayout' +import { SettingsNavigation } from '~/modules/settings' +import { CustomDomainCard } from '~/modules/site-settings' + +export function Component() { + const { t } = useTranslation() + return ( + +
+ + +
+
+ ) +} diff --git a/be/apps/dashboard/src/pages/superadmin/debug.tsx b/be/apps/dashboard/src/pages/superadmin/debug.tsx index 5841a453..9462712d 100644 --- a/be/apps/dashboard/src/pages/superadmin/debug.tsx +++ b/be/apps/dashboard/src/pages/superadmin/debug.tsx @@ -8,7 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import { LinearBorderPanel } from '~/components/common/GlassPanel' +import { LinearBorderPanel } from '~/components/common/LinearBorderPanel' import { getI18n } from '~/i18n' import { getRequestErrorMessage } from '~/lib/errors' import type { PhotoSyncLogLevel } from '~/modules/photos/types' diff --git a/be/apps/dashboard/vite.config.ts b/be/apps/dashboard/vite.config.ts index cef1e8b4..22fdf6f4 100644 --- a/be/apps/dashboard/vite.config.ts +++ b/be/apps/dashboard/vite.config.ts @@ -15,7 +15,6 @@ import { astPlugin } from '../../../plugins/vite/ast' import PKG from './package.json' const ROOT = fileURLToPath(new URL('./', import.meta.url)) -const API_TARGET = process.env.CORE_API_URL || 'http://localhost:3000' export default defineConfig({ plugins: [ @@ -58,7 +57,7 @@ export default defineConfig({ }, proxy: { '/api': { - target: API_TARGET, + target: 'http://localhost:1841', changeOrigin: true, xfwd: true, // keep path as-is so /api -> backend /api diff --git a/be/packages/db/migrations/0005_flawless_wild_pack.sql b/be/packages/db/migrations/0005_flawless_wild_pack.sql new file mode 100644 index 00000000..b8f526aa --- /dev/null +++ b/be/packages/db/migrations/0005_flawless_wild_pack.sql @@ -0,0 +1,15 @@ +CREATE TYPE "public"."tenant_domain_status" AS ENUM('pending', 'verified', 'disabled');--> statement-breakpoint +CREATE TABLE "tenant_domain" ( + "id" text PRIMARY KEY NOT NULL, + "tenant_id" text NOT NULL, + "domain" text NOT NULL, + "status" "tenant_domain_status" DEFAULT 'pending' NOT NULL, + "verification_token" text NOT NULL, + "verified_at" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "uq_tenant_domain_domain" UNIQUE("domain") +); +--> statement-breakpoint +ALTER TABLE "tenant_domain" ADD CONSTRAINT "tenant_domain_tenant_id_tenant_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_tenant_domain_tenant" ON "tenant_domain" USING btree ("tenant_id"); \ No newline at end of file diff --git a/be/packages/db/migrations/meta/0005_snapshot.json b/be/packages/db/migrations/meta/0005_snapshot.json new file mode 100644 index 00000000..9d374a67 --- /dev/null +++ b/be/packages/db/migrations/meta/0005_snapshot.json @@ -0,0 +1,1587 @@ +{ + "id": "3a791767-7308-42cd-ad3a-80e5ae4d9eaf", + "prevId": "95dbb6b6-150d-43cb-81e4-4d31e2540f25", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_account": { + "name": "auth_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "auth_account_user_id_auth_user_id_fk": { + "name": "auth_account_user_id_auth_user_id_fk", + "tableFrom": "auth_account", + "tableTo": "auth_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_session": { + "name": "auth_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "auth_session_tenant_id_tenant_id_fk": { + "name": "auth_session_tenant_id_tenant_id_fk", + "tableFrom": "auth_session", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "auth_session_user_id_auth_user_id_fk": { + "name": "auth_session_user_id_auth_user_id_fk", + "tableFrom": "auth_session", + "tableTo": "auth_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_session_token_unique": { + "name": "auth_session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_user": { + "name": "auth_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creem_customer_id": { + "name": "creem_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires_at": { + "name": "ban_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "auth_user_tenant_id_tenant_id_fk": { + "name": "auth_user_tenant_id_tenant_id_fk", + "tableFrom": "auth_user", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_user_email_unique": { + "name": "auth_user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_verification": { + "name": "auth_verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_usage_event": { + "name": "billing_usage_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'count'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_billing_usage_event_tenant": { + "name": "idx_billing_usage_event_tenant", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_billing_usage_event_type": { + "name": "idx_billing_usage_event_type", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_usage_event_tenant_id_tenant_id_fk": { + "name": "billing_usage_event_tenant_id_tenant_id_fk", + "tableFrom": "billing_usage_event", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.creem_subscription": { + "name": "creem_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creem_customer_id": { + "name": "creem_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creem_subscription_id": { + "name": "creem_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creem_order_id": { + "name": "creem_order_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.photo_asset": { + "name": "photo_asset", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "photo_id": { + "name": "photo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_modified": { + "name": "last_modified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata_hash": { + "name": "metadata_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manifest_version": { + "name": "manifest_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'v7'" + }, + "manifest": { + "name": "manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "sync_status": { + "name": "sync_status", + "type": "photo_sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "conflict_reason": { + "name": "conflict_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conflict_payload": { + "name": "conflict_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "photo_asset_tenant_id_tenant_id_fk": { + "name": "photo_asset_tenant_id_tenant_id_fk", + "tableFrom": "photo_asset", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_photo_asset_tenant_storage_key": { + "name": "uq_photo_asset_tenant_storage_key", + "nullsNotDistinct": false, + "columns": ["tenant_id", "storage_key"] + }, + "uq_photo_asset_tenant_photo_id": { + "name": "uq_photo_asset_tenant_photo_id", + "nullsNotDistinct": false, + "columns": ["tenant_id", "photo_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.photo_sync_run": { + "name": "photo_sync_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dry_run": { + "name": "dry_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "summary": { + "name": "summary", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "actions_count": { + "name": "actions_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_photo_sync_run_tenant": { + "name": "idx_photo_sync_run_tenant", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "photo_sync_run_tenant_id_tenant_id_fk": { + "name": "photo_sync_run_tenant_id_tenant_id_fk", + "tableFrom": "photo_sync_run", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reactions": { + "name": "reactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ref_key": { + "name": "ref_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reaction": { + "name": "reaction", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_reactions_tenant_ref_key": { + "name": "idx_reactions_tenant_ref_key", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ref_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reactions_tenant_id_tenant_id_fk": { + "name": "reactions_tenant_id_tenant_id_fk", + "tableFrom": "reactions", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_sensitive": { + "name": "is_sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_tenant_id_tenant_id_fk": { + "name": "settings_tenant_id_tenant_id_fk", + "tableFrom": "settings", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_settings_tenant_key": { + "name": "uq_settings_tenant_key", + "nullsNotDistinct": false, + "columns": ["tenant_id", "key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_setting": { + "name": "system_setting", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "is_sensitive": { + "name": "is_sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_system_setting_key": { + "name": "uq_system_setting_key", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant_auth_account": { + "name": "tenant_auth_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "tenant_auth_account_tenant_id_tenant_id_fk": { + "name": "tenant_auth_account_tenant_id_tenant_id_fk", + "tableFrom": "tenant_auth_account", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tenant_auth_account_user_id_tenant_auth_user_id_fk": { + "name": "tenant_auth_account_user_id_tenant_auth_user_id_fk", + "tableFrom": "tenant_auth_account", + "tableTo": "tenant_auth_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant_auth_session": { + "name": "tenant_auth_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tenant_auth_session_tenant_id_tenant_id_fk": { + "name": "tenant_auth_session_tenant_id_tenant_id_fk", + "tableFrom": "tenant_auth_session", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tenant_auth_session_user_id_tenant_auth_user_id_fk": { + "name": "tenant_auth_session_user_id_tenant_auth_user_id_fk", + "tableFrom": "tenant_auth_session", + "tableTo": "tenant_auth_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tenant_auth_session_token_unique": { + "name": "tenant_auth_session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant_auth_user": { + "name": "tenant_auth_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'guest'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires_at": { + "name": "ban_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "tenant_auth_user_tenant_id_tenant_id_fk": { + "name": "tenant_auth_user_tenant_id_tenant_id_fk", + "tableFrom": "tenant_auth_user", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_tenant_auth_user_tenant_email": { + "name": "uq_tenant_auth_user_tenant_email", + "nullsNotDistinct": false, + "columns": ["tenant_id", "email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant_domain": { + "name": "tenant_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "tenant_domain_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_tenant_domain_tenant": { + "name": "idx_tenant_domain_tenant", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tenant_domain_tenant_id_tenant_id_fk": { + "name": "tenant_domain_tenant_id_tenant_id_fk", + "tableFrom": "tenant_domain", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_tenant_domain_domain": { + "name": "uq_tenant_domain_domain", + "nullsNotDistinct": false, + "columns": ["domain"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant": { + "name": "tenant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "tenant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'inactive'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_tenant_slug": { + "name": "uq_tenant_slug", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.photo_sync_status": { + "name": "photo_sync_status", + "schema": "public", + "values": ["pending", "synced", "conflict"] + }, + "public.tenant_domain_status": { + "name": "tenant_domain_status", + "schema": "public", + "values": ["pending", "verified", "disabled"] + }, + "public.tenant_status": { + "name": "tenant_status", + "schema": "public", + "values": ["active", "inactive", "suspended"] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": ["user", "admin", "superadmin"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/be/packages/db/migrations/meta/_journal.json b/be/packages/db/migrations/meta/_journal.json index 73957731..b3bf08ef 100644 --- a/be/packages/db/migrations/meta/_journal.json +++ b/be/packages/db/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1763315183401, "tag": "0004_aromatic_bishop", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1763626275917, + "tag": "0005_flawless_wild_pack", + "breakpoints": true } ] } diff --git a/be/packages/db/src/schema.ts b/be/packages/db/src/schema.ts index 9e3176e9..d21d3641 100644 --- a/be/packages/db/src/schema.ts +++ b/be/packages/db/src/schema.ts @@ -15,6 +15,7 @@ const snowflakeId = createSnowflakeId('id').primaryKey() export const userRoleEnum = pgEnum('user_role', ['user', 'admin', 'superadmin']) export const tenantStatusEnum = pgEnum('tenant_status', ['active', 'inactive', 'suspended']) +export const tenantDomainStatusEnum = pgEnum('tenant_domain_status', ['pending', 'verified', 'disabled']) export const photoSyncStatusEnum = pgEnum('photo_sync_status', ['pending', 'synced', 'conflict']) export const CURRENT_PHOTO_MANIFEST_VERSION = 'v7' as const @@ -69,6 +70,23 @@ export const tenants = pgTable( (t) => [unique('uq_tenant_slug').on(t.slug)], ) +export const tenantDomains = pgTable( + 'tenant_domain', + { + id: snowflakeId, + tenantId: text('tenant_id') + .notNull() + .references(() => tenants.id, { onDelete: 'cascade' }), + domain: text('domain').notNull(), + status: tenantDomainStatusEnum('status').notNull().default('pending'), + verificationToken: text('verification_token').notNull(), + verifiedAt: timestamp('verified_at', { mode: 'string' }), + createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(), + }, + (t) => [unique('uq_tenant_domain_domain').on(t.domain), index('idx_tenant_domain_tenant').on(t.tenantId)], +) + // Custom users table (Better Auth: user) export const authUsers = pgTable('auth_user', { id: text('id').primaryKey(), @@ -326,6 +344,7 @@ export const billingUsageEvents = pgTable( export const dbSchema = { tenants, + tenantDomains, authUsers, authSessions, authAccounts, diff --git a/be/session b/be/session new file mode 100644 index 00000000..221f7436 --- /dev/null +++ b/be/session @@ -0,0 +1 @@ +Default Site

Default Site

Default Site

\ No newline at end of file diff --git a/locales/dashboard/en.json b/locales/dashboard/en.json index 8ad3deee..a68d4af7 100644 --- a/locales/dashboard/en.json +++ b/locales/dashboard/en.json @@ -476,8 +476,38 @@ "settings.account.title": "Account & Login", "settings.data.description": "Run database maintenance tasks to keep photo data consistent with object storage.", "settings.data.title": "Data Management", + "settings.domain.actions.verify": "Verify", + "settings.domain.banner.pending": "Pending verification for {{domain}}. DNS changes may take a few minutes to propagate.", + "settings.domain.bound-list.empty": "No custom domains yet. Add one on the left to start verification.", + "settings.domain.bound-list.title": "Bound domains", + "settings.domain.description": "Bind your own domain to serve the gallery under a branded URL. We support CNAME or TXT verification.", + "settings.domain.input.cta": "Bind domain", + "settings.domain.input.helper": "Use the root domain or a subdomain. Avoid the platform base domain {{base}} itself.", + "settings.domain.input.label": "Custom domain", + "settings.domain.input.placeholder": "photos.yourdomain.com", + "settings.domain.status.disabled": "Disabled", + "settings.domain.status.pending": "Pending DNS", + "settings.domain.status.verified": "Active", + "settings.domain.steps.cname.desc": "Create a CNAME record pointing to your workspace entry. This is the recommended approach.", + "settings.domain.steps.cname.title": "Add a CNAME record", + "settings.domain.steps.title": "Verification steps", + "settings.domain.steps.txt.desc": "Alternatively, add a TXT record with the verification token below if CNAME is not available.", + "settings.domain.steps.txt.title": "Optional TXT verification", + "settings.domain.steps.verify.desc": "After DNS propagates, click Verify. We will also accept the TXT token if present.", + "settings.domain.steps.verify.title": "Verify and publish", + "settings.domain.title": "Custom domain", + "settings.domain.toast.delete-failed": "Failed to remove domain", + "settings.domain.toast.delete-success": "Domain removed", + "settings.domain.toast.input-required": "Please enter a domain before binding.", + "settings.domain.toast.request-failed": "Failed to bind domain", + "settings.domain.toast.request-success": "Domain added. Please complete DNS and verify.", + "settings.domain.toast.verify-failed": "Verification failed. Please verify DNS and try again.", + "settings.domain.toast.verify-success": "Domain verified and activated", + "settings.domain.token.helper": "Place this token in a TXT record if you cannot point a CNAME.", + "settings.domain.token.label": "Verification token", "settings.nav.account": "Account & Login", "settings.nav.data": "Data Management", + "settings.nav.domain": "Custom Domain", "settings.nav.site": "Site Settings", "settings.nav.user": "User Profile", "settings.site.description": "Configure branding, social links, and map display for the public site.", diff --git a/locales/dashboard/zh-CN.json b/locales/dashboard/zh-CN.json index 206b7df3..c1166454 100644 --- a/locales/dashboard/zh-CN.json +++ b/locales/dashboard/zh-CN.json @@ -476,8 +476,38 @@ "settings.account.title": "账号与登录", "settings.data.description": "执行数据库级别的维护操作,以保持照片数据与对象存储一致。", "settings.data.title": "数据管理", + "settings.domain.actions.verify": "验证", + "settings.domain.banner.pending": "{{domain}} 正在验证中,DNS 生效可能需要几分钟。", + "settings.domain.bound-list.empty": "还没有绑定域名,请在左侧输入后开始验证。", + "settings.domain.bound-list.title": "已绑定域名", + "settings.domain.description": "绑定自己的域名,让相册以品牌化地址访问。支持 CNAME 或 TXT 验证。", + "settings.domain.input.cta": "绑定域名", + "settings.domain.input.helper": "可以使用根域或子域,避免直接填写平台基础域名 {{base}}。", + "settings.domain.input.label": "自定义域名", + "settings.domain.input.placeholder": "photos.example.com", + "settings.domain.status.disabled": "已停用", + "settings.domain.status.pending": "等待 DNS", + "settings.domain.status.verified": "已生效", + "settings.domain.steps.cname.desc": "创建指向工作空间入口的 CNAME 记录,推荐优先使用。", + "settings.domain.steps.cname.title": "添加 CNAME 记录", + "settings.domain.steps.title": "验证步骤", + "settings.domain.steps.txt.desc": "若无法使用 CNAME,可添加 TXT 记录并填写下方验证 Token。", + "settings.domain.steps.txt.title": "可选 TXT 验证", + "settings.domain.steps.verify.desc": "DNS 生效后点击验证。如果存在 TXT Token 也会一并校验。", + "settings.domain.steps.verify.title": "完成验证并生效", + "settings.domain.title": "自定义域名", + "settings.domain.toast.delete-failed": "移除域名失败", + "settings.domain.toast.delete-success": "域名已移除", + "settings.domain.toast.input-required": "请先输入要绑定的域名。", + "settings.domain.toast.request-failed": "绑定域名失败", + "settings.domain.toast.request-success": "域名已添加,请完成 DNS 配置后再验证。", + "settings.domain.toast.verify-failed": "验证失败,请检查 DNS 后重试。", + "settings.domain.toast.verify-success": "域名已验证并启用", + "settings.domain.token.helper": "无法配置 CNAME 时,可将此 Token 写入 TXT 记录完成验证。", + "settings.domain.token.label": "验证 Token", "settings.nav.account": "账号与登录", "settings.nav.data": "数据管理", + "settings.nav.domain": "自定义域名", "settings.nav.site": "站点设置", "settings.nav.user": "用户信息", "settings.site.description": "配置前台站点的品牌信息、社交渠道与地图展示。",