diff --git a/apps/admin-web/src/i18n/messages/en-US.ts b/apps/admin-web/src/i18n/messages/en-US.ts index 30fb6d5..22929e1 100644 --- a/apps/admin-web/src/i18n/messages/en-US.ts +++ b/apps/admin-web/src/i18n/messages/en-US.ts @@ -102,6 +102,8 @@ const enUS = { eyebrow: "Access Paused", title: "This tenant plan is unavailable", expiredMessage: "Membership expired. Please renew or contact support.", + userDisabledMessage: "User has been disabled. Please contact support.", + reasonUserDisabled: "This user has been disabled. Business pages are now locked. Please contact support to restore access.", reasonTrialExpired: "The free trial's 3-day page access window has ended. All business pages are now locked. Please contact an administrator to reactivate trial access.", reasonRequired: "This tenant does not have an active plan. All business pages are now locked. Please contact an administrator.", reasonInactive: "This tenant plan has expired or been disabled. All business pages are now locked. Please contact an administrator to restore access.", diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts index c803ceb..3587b27 100644 --- a/apps/admin-web/src/i18n/messages/zh-CN.ts +++ b/apps/admin-web/src/i18n/messages/zh-CN.ts @@ -102,6 +102,8 @@ const zhCN = { eyebrow: "访问已暂停", title: "当前租户套餐不可用", expiredMessage: "会员到期,请充值或联系客服", + userDisabledMessage: "用户被停用,请联系客服", + reasonUserDisabled: "用户已被停用,业务页面已全部关闭,请联系客服恢复访问。", reasonTrialExpired: "免费试用的 3 天页面可访问期已经结束,当前业务页面已全部关闭,请联系管理员重新开通试用权限。", reasonRequired: "当前租户尚未开通有效套餐,业务页面已全部关闭,请联系管理员处理。", reasonInactive: "当前租户套餐已失效或已停用,业务页面已全部关闭,请联系管理员恢复访问。", diff --git a/apps/admin-web/src/layouts/AppShell.vue b/apps/admin-web/src/layouts/AppShell.vue index b941f42..2b657dc 100644 --- a/apps/admin-web/src/layouts/AppShell.vue +++ b/apps/admin-web/src/layouts/AppShell.vue @@ -25,6 +25,11 @@ const authStore = useAuthStore(); const { locale, t } = useI18n(); const isMembershipBlocked = computed(() => authStore.isMembershipBlocked); +const membershipBlockedMessage = computed(() => ( + authStore.membership?.blocked_reason === "user_disabled" + ? t("membershipBlocked.userDisabledMessage") + : t("membershipBlocked.expiredMessage") +)); const quotaQuery = useQuery({ queryKey: ["workspace", "quota-summary", "shell"], @@ -358,7 +363,7 @@ async function handleLogout(): Promise { aria-live="polite" > -

{{ t("membershipBlocked.expiredMessage") }}

+

{{ membershipBlockedMessage }}

diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 4558676..8179a16 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -273,6 +273,7 @@ export const apiClient = createApiClient({ }); const membershipBlockedErrors = new Set([ + "user_disabled", "trial_plan_expired", "subscription_required", "subscription_inactive", diff --git a/apps/admin-web/src/lib/session.ts b/apps/admin-web/src/lib/session.ts index 12a7aa7..6a2e962 100644 --- a/apps/admin-web/src/lib/session.ts +++ b/apps/admin-web/src/lib/session.ts @@ -107,7 +107,7 @@ export function markStoredMembershipBlocked(reason = "subscription_inactive"): S membership: { plan_code: current?.plan_code ?? "", plan_name: current?.plan_name ?? "", - status: "expired", + status: reason === "user_disabled" ? "disabled" : "expired", blocked: true, blocked_reason: reason, start_at: current?.start_at ?? null, diff --git a/apps/admin-web/src/views/MembershipBlockedView.vue b/apps/admin-web/src/views/MembershipBlockedView.vue index f736563..b156237 100644 --- a/apps/admin-web/src/views/MembershipBlockedView.vue +++ b/apps/admin-web/src/views/MembershipBlockedView.vue @@ -16,6 +16,8 @@ const stillBlocked = ref(false); const blockedReasonText = computed(() => { switch (membership.value?.blocked_reason) { + case "user_disabled": + return t("membershipBlocked.reasonUserDisabled"); case "trial_plan_expired": return t("membershipBlocked.reasonTrialExpired"); case "subscription_required": diff --git a/server/internal/tenant/transport/router.go b/server/internal/tenant/transport/router.go index 82dd3ed..e264cd5 100644 --- a/server/internal/tenant/transport/router.go +++ b/server/internal/tenant/transport/router.go @@ -65,7 +65,10 @@ func RegisterRoutes(app *bootstrap.App) { desktopAuth.POST("/monitoring/tasks/:id/cancel", desktopMonitoringHandler.Cancel) tenantProtected := protected.Group("/tenant") - tenantProtected.Use(RequireActiveTenantSubscription(repository.NewTenantPlanRepository(app.DB))) + tenantProtected.Use(RequireActiveTenantSubscription( + repository.NewTenantPlanRepository(app.DB), + repository.NewAuthRepository(app.DB), + )) tenantProtected.GET("/accounts", desktopAccountHandler.TenantList) tenantProtected.POST("/accounts/:id/request-delete", desktopAccountHandler.RequestDelete) tenantProtected.POST("/tasks/:id/reconcile", desktopTaskHandler.Reconcile) diff --git a/server/internal/tenant/transport/subscription_guard.go b/server/internal/tenant/transport/subscription_guard.go index 22320b1..405c234 100644 --- a/server/internal/tenant/transport/subscription_guard.go +++ b/server/internal/tenant/transport/subscription_guard.go @@ -1,16 +1,22 @@ package transport import ( + "errors" + "strings" "time" "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5" "github.com/geo-platform/tenant-api/internal/shared/auth" "github.com/geo-platform/tenant-api/internal/shared/response" "github.com/geo-platform/tenant-api/internal/tenant/repository" ) -func RequireActiveTenantSubscription(plans repository.TenantPlanRepository) gin.HandlerFunc { +func RequireActiveTenantSubscription( + plans repository.TenantPlanRepository, + authRepos ...repository.AuthRepository, +) gin.HandlerFunc { return func(c *gin.Context) { actor, ok := auth.ActorFromCtx(c.Request.Context()) if !ok { @@ -19,6 +25,28 @@ func RequireActiveTenantSubscription(plans repository.TenantPlanRepository) gin. return } + if len(authRepos) > 0 && authRepos[0] != nil { + user, err := authRepos[0].GetUserByID(c.Request.Context(), actor.UserID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + response.Error(c, response.ErrNotFound(40401, "user_not_found", "user not found")) + } else { + response.Error(c, response.ErrInternal(50092, "user_lookup_failed", "failed to load user")) + } + c.Abort() + return + } + if !strings.EqualFold(strings.TrimSpace(user.Status), "active") { + response.Error(c, response.ErrForbidden( + 40311, + "user_disabled", + "user account is disabled, please contact support", + )) + c.Abort() + return + } + } + now := time.Now().UTC() access, err := plans.GetTenantPlanAccess(c.Request.Context(), actor.TenantID, now) if err != nil {