feat(auth): block disabled user accounts at the subscription guard
RequireActiveTenantSubscription now optionally takes an AuthRepository and rejects the request with 40311/user_disabled when the actor's user row is anything other than active. Wire the repo through the tenant router, surface the new error code in admin-web's membershipBlockedErrors set, persist a "disabled" membership status when markStoredMembershipBlocked sees user_disabled, and add localized copy on the blocked screen and shell membership banner. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -102,6 +102,8 @@ const enUS = {
|
|||||||
eyebrow: "Access Paused",
|
eyebrow: "Access Paused",
|
||||||
title: "This tenant plan is unavailable",
|
title: "This tenant plan is unavailable",
|
||||||
expiredMessage: "Membership expired. Please renew or contact support.",
|
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.",
|
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.",
|
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.",
|
reasonInactive: "This tenant plan has expired or been disabled. All business pages are now locked. Please contact an administrator to restore access.",
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ const zhCN = {
|
|||||||
eyebrow: "访问已暂停",
|
eyebrow: "访问已暂停",
|
||||||
title: "当前租户套餐不可用",
|
title: "当前租户套餐不可用",
|
||||||
expiredMessage: "会员到期,请充值或联系客服",
|
expiredMessage: "会员到期,请充值或联系客服",
|
||||||
|
userDisabledMessage: "用户被停用,请联系客服",
|
||||||
|
reasonUserDisabled: "用户已被停用,业务页面已全部关闭,请联系客服恢复访问。",
|
||||||
reasonTrialExpired: "免费试用的 3 天页面可访问期已经结束,当前业务页面已全部关闭,请联系管理员重新开通试用权限。",
|
reasonTrialExpired: "免费试用的 3 天页面可访问期已经结束,当前业务页面已全部关闭,请联系管理员重新开通试用权限。",
|
||||||
reasonRequired: "当前租户尚未开通有效套餐,业务页面已全部关闭,请联系管理员处理。",
|
reasonRequired: "当前租户尚未开通有效套餐,业务页面已全部关闭,请联系管理员处理。",
|
||||||
reasonInactive: "当前租户套餐已失效或已停用,业务页面已全部关闭,请联系管理员恢复访问。",
|
reasonInactive: "当前租户套餐已失效或已停用,业务页面已全部关闭,请联系管理员恢复访问。",
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ const authStore = useAuthStore();
|
|||||||
const { locale, t } = useI18n();
|
const { locale, t } = useI18n();
|
||||||
|
|
||||||
const isMembershipBlocked = computed(() => authStore.isMembershipBlocked);
|
const isMembershipBlocked = computed(() => authStore.isMembershipBlocked);
|
||||||
|
const membershipBlockedMessage = computed(() => (
|
||||||
|
authStore.membership?.blocked_reason === "user_disabled"
|
||||||
|
? t("membershipBlocked.userDisabledMessage")
|
||||||
|
: t("membershipBlocked.expiredMessage")
|
||||||
|
));
|
||||||
|
|
||||||
const quotaQuery = useQuery({
|
const quotaQuery = useQuery({
|
||||||
queryKey: ["workspace", "quota-summary", "shell"],
|
queryKey: ["workspace", "quota-summary", "shell"],
|
||||||
@@ -358,7 +363,7 @@ async function handleLogout(): Promise<void> {
|
|||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
<div class="membership-expired-logo" :aria-label="t('app.name')">GEO</div>
|
<div class="membership-expired-logo" :aria-label="t('app.name')">GEO</div>
|
||||||
<p>{{ t("membershipBlocked.expiredMessage") }}</p>
|
<p>{{ membershipBlockedMessage }}</p>
|
||||||
</section>
|
</section>
|
||||||
<router-view v-else />
|
<router-view v-else />
|
||||||
</a-layout-content>
|
</a-layout-content>
|
||||||
|
|||||||
@@ -273,6 +273,7 @@ export const apiClient = createApiClient({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const membershipBlockedErrors = new Set([
|
const membershipBlockedErrors = new Set([
|
||||||
|
"user_disabled",
|
||||||
"trial_plan_expired",
|
"trial_plan_expired",
|
||||||
"subscription_required",
|
"subscription_required",
|
||||||
"subscription_inactive",
|
"subscription_inactive",
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export function markStoredMembershipBlocked(reason = "subscription_inactive"): S
|
|||||||
membership: {
|
membership: {
|
||||||
plan_code: current?.plan_code ?? "",
|
plan_code: current?.plan_code ?? "",
|
||||||
plan_name: current?.plan_name ?? "",
|
plan_name: current?.plan_name ?? "",
|
||||||
status: "expired",
|
status: reason === "user_disabled" ? "disabled" : "expired",
|
||||||
blocked: true,
|
blocked: true,
|
||||||
blocked_reason: reason,
|
blocked_reason: reason,
|
||||||
start_at: current?.start_at ?? null,
|
start_at: current?.start_at ?? null,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const stillBlocked = ref(false);
|
|||||||
|
|
||||||
const blockedReasonText = computed(() => {
|
const blockedReasonText = computed(() => {
|
||||||
switch (membership.value?.blocked_reason) {
|
switch (membership.value?.blocked_reason) {
|
||||||
|
case "user_disabled":
|
||||||
|
return t("membershipBlocked.reasonUserDisabled");
|
||||||
case "trial_plan_expired":
|
case "trial_plan_expired":
|
||||||
return t("membershipBlocked.reasonTrialExpired");
|
return t("membershipBlocked.reasonTrialExpired");
|
||||||
case "subscription_required":
|
case "subscription_required":
|
||||||
|
|||||||
@@ -65,7 +65,10 @@ func RegisterRoutes(app *bootstrap.App) {
|
|||||||
desktopAuth.POST("/monitoring/tasks/:id/cancel", desktopMonitoringHandler.Cancel)
|
desktopAuth.POST("/monitoring/tasks/:id/cancel", desktopMonitoringHandler.Cancel)
|
||||||
|
|
||||||
tenantProtected := protected.Group("/tenant")
|
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.GET("/accounts", desktopAccountHandler.TenantList)
|
||||||
tenantProtected.POST("/accounts/:id/request-delete", desktopAccountHandler.RequestDelete)
|
tenantProtected.POST("/accounts/:id/request-delete", desktopAccountHandler.RequestDelete)
|
||||||
tenantProtected.POST("/tasks/:id/reconcile", desktopTaskHandler.Reconcile)
|
tenantProtected.POST("/tasks/:id/reconcile", desktopTaskHandler.Reconcile)
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
package transport
|
package transport
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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/auth"
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||||
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
"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) {
|
return func(c *gin.Context) {
|
||||||
actor, ok := auth.ActorFromCtx(c.Request.Context())
|
actor, ok := auth.ActorFromCtx(c.Request.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -19,6 +25,28 @@ func RequireActiveTenantSubscription(plans repository.TenantPlanRepository) gin.
|
|||||||
return
|
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()
|
now := time.Now().UTC()
|
||||||
access, err := plans.GetTenantPlanAccess(c.Request.Context(), actor.TenantID, now)
|
access, err := plans.GetTenantPlanAccess(c.Request.Context(), actor.TenantID, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user