feat(admin-web/auth): support phone-or-email login and dim shell on expiry
Login form takes a phone-or-email identifier; user info now exposes phone so the shell can fall back through name → phone → email. When membership expires, the app stays on the current route but renders a minimal blocked state in the shell instead of redirecting to a dedicated page; an Axios interceptor flips the local session into the blocked state when the API returns trial/subscription errors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -94,6 +94,7 @@ const enUS = {
|
||||
tenantAdminLogin: "Tenant Admin Login",
|
||||
loginAndEnter: "Sign in to workspace",
|
||||
email: "Email",
|
||||
loginIdentifier: "Phone / Email",
|
||||
password: "Password",
|
||||
defaultTestAccount: "Default account",
|
||||
defaultTestPassword: "Default password",
|
||||
@@ -101,6 +102,7 @@ const enUS = {
|
||||
membershipBlocked: {
|
||||
eyebrow: "Access Paused",
|
||||
title: "This tenant plan is unavailable",
|
||||
expiredMessage: "Membership expired. Please renew or contact support.",
|
||||
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.",
|
||||
@@ -118,6 +120,7 @@ const enUS = {
|
||||
localeLabel: "Language",
|
||||
quotaTitle: "Plan & Quota",
|
||||
quotaStatus: "Healthy",
|
||||
membershipExpiresAt: "Expires",
|
||||
remainingQuota: "Article quota",
|
||||
aiPoints: "AI points",
|
||||
resetAt: "Reset at",
|
||||
|
||||
@@ -94,6 +94,7 @@ const zhCN = {
|
||||
tenantAdminLogin: "租户后台登录",
|
||||
loginAndEnter: "登录并进入工作台",
|
||||
email: "邮箱",
|
||||
loginIdentifier: "手机号 / 邮箱",
|
||||
password: "密码",
|
||||
defaultTestAccount: "默认测试账号",
|
||||
defaultTestPassword: "默认测试密码",
|
||||
@@ -101,6 +102,7 @@ const zhCN = {
|
||||
membershipBlocked: {
|
||||
eyebrow: "访问已暂停",
|
||||
title: "当前租户套餐不可用",
|
||||
expiredMessage: "会员到期,请充值或联系客服",
|
||||
reasonTrialExpired: "免费试用的 3 天页面可访问期已经结束,当前业务页面已全部关闭,请联系管理员重新开通试用权限。",
|
||||
reasonRequired: "当前租户尚未开通有效套餐,业务页面已全部关闭,请联系管理员处理。",
|
||||
reasonInactive: "当前租户套餐已失效或已停用,业务页面已全部关闭,请联系管理员恢复访问。",
|
||||
@@ -118,6 +120,7 @@ const zhCN = {
|
||||
localeLabel: "语言",
|
||||
quotaTitle: "套餐与额度",
|
||||
quotaStatus: "状态正常",
|
||||
membershipExpiresAt: "会员到期",
|
||||
remainingQuota: "生成文章",
|
||||
aiPoints: "AI 点数",
|
||||
resetAt: "重置时间",
|
||||
|
||||
@@ -24,13 +24,37 @@ const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const { locale, t } = useI18n();
|
||||
|
||||
const isMembershipBlocked = computed(() => authStore.isMembershipBlocked);
|
||||
|
||||
const quotaQuery = useQuery({
|
||||
queryKey: ["workspace", "quota-summary", "shell"],
|
||||
queryFn: () => workspaceApi.quotaSummary(),
|
||||
enabled: computed(() => !isMembershipBlocked.value),
|
||||
});
|
||||
|
||||
const userInitial = computed(() => authStore.user?.name?.slice(0, 1).toUpperCase() ?? "A");
|
||||
const userDisplayName = computed(() => {
|
||||
return authStore.user?.name || authStore.user?.phone || authStore.user?.email || t("shell.userFallback");
|
||||
});
|
||||
const userSecondaryIdentifier = computed(() => authStore.user?.email || authStore.user?.phone || "--");
|
||||
const userInitial = computed(() => userDisplayName.value.slice(0, 1).toUpperCase() || "A");
|
||||
const quotaSummary = computed(() => quotaQuery.data.value);
|
||||
const membershipExpiryText = computed(() => {
|
||||
const value = authStore.membership?.end_at;
|
||||
if (!value) {
|
||||
return "--";
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(date);
|
||||
});
|
||||
const selectedKeys = computed(() => {
|
||||
if (route.meta.navKey === null) {
|
||||
return [];
|
||||
@@ -242,9 +266,9 @@ async function handleLogout(): Promise<void> {
|
||||
</a-menu>
|
||||
</a-layout-sider>
|
||||
|
||||
<a-layout class="admin-main-layout">
|
||||
<a-layout-header class="admin-header">
|
||||
<nav class="admin-header-breadcrumb" aria-label="Breadcrumb">
|
||||
<a-layout class="admin-main-layout" :class="{ 'admin-main-layout--blocked': isMembershipBlocked }">
|
||||
<a-layout-header class="admin-header" :class="{ 'admin-header--blocked': isMembershipBlocked }">
|
||||
<nav v-if="!isMembershipBlocked" class="admin-header-breadcrumb" aria-label="Breadcrumb">
|
||||
<span
|
||||
v-for="(crumb, index) in headerBreadcrumbs"
|
||||
:key="`${crumb.label}-${index}`"
|
||||
@@ -269,7 +293,7 @@ async function handleLogout(): Promise<void> {
|
||||
</nav>
|
||||
|
||||
<div class="admin-header-actions">
|
||||
<div class="admin-locale-switch">
|
||||
<div v-if="!isMembershipBlocked" class="admin-locale-switch">
|
||||
<a-dropdown placement="bottomRight" :trigger="['click', 'hover']">
|
||||
<div class="locale-dropdown-trigger">
|
||||
<GlobalOutlined class="locale-icon" />
|
||||
@@ -286,9 +310,11 @@ async function handleLogout(): Promise<void> {
|
||||
</div>
|
||||
|
||||
<!-- Quota Indicator -->
|
||||
<div class="quota-pill">
|
||||
<div v-if="!isMembershipBlocked" class="quota-pill">
|
||||
<a-tag color="blue" :bordered="false" class="quota-pill-tag">{{ quotaSummary?.plan_name || t("shell.planFallback") }}</a-tag>
|
||||
<span class="quota-pill-text">
|
||||
{{ t("shell.membershipExpiresAt") }}: <strong>{{ membershipExpiryText }}</strong>
|
||||
<span class="quota-pill-divider">·</span>
|
||||
{{ t("shell.remainingQuota") }}: <strong>{{ quotaSummary?.balance ?? '--' }}</strong>
|
||||
<span class="quota-pill-total">/ {{ quotaSummary?.total_quota ?? '--' }}</span>
|
||||
<span class="quota-pill-divider">·</span>
|
||||
@@ -301,17 +327,17 @@ async function handleLogout(): Promise<void> {
|
||||
<a-dropdown placement="bottomRight" :trigger="['click', 'hover']">
|
||||
<div class="user-dropdown-trigger">
|
||||
<a-avatar size="small" class="user-avatar">{{ userInitial }}</a-avatar>
|
||||
<span class="user-name">{{ authStore.user?.name || t("shell.userFallback") }}</span>
|
||||
<span class="user-name">{{ userDisplayName }}</span>
|
||||
<DownOutlined class="user-dropdown-icon" />
|
||||
</div>
|
||||
<template #overlay>
|
||||
<a-menu class="user-dropdown-menu">
|
||||
<div class="user-dropdown-header">
|
||||
<span class="user-dropdown-name">{{ authStore.user?.name || t("shell.userFallback") }}</span>
|
||||
<span class="user-dropdown-role">{{ authStore.user?.email || "--" }}</span>
|
||||
<span class="user-dropdown-name">{{ userDisplayName }}</span>
|
||||
<span class="user-dropdown-role">{{ userSecondaryIdentifier }}</span>
|
||||
</div>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="workspace" @click="go('/workspace')" class="user-dropdown-item">
|
||||
<a-menu-item v-if="!isMembershipBlocked" key="workspace" @click="go('/workspace')" class="user-dropdown-item">
|
||||
{{ t("nav.workspace") }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="logout" @click="handleLogout" class="user-dropdown-item user-dropdown-logout">
|
||||
@@ -324,8 +350,17 @@ async function handleLogout(): Promise<void> {
|
||||
</div>
|
||||
</a-layout-header>
|
||||
|
||||
<a-layout-content class="admin-content">
|
||||
<router-view />
|
||||
<a-layout-content class="admin-content" :class="{ 'admin-content--blocked': isMembershipBlocked }">
|
||||
<section
|
||||
v-if="isMembershipBlocked"
|
||||
class="membership-expired-state"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="membership-expired-logo" :aria-label="t('app.name')">GEO</div>
|
||||
<p>{{ t("membershipBlocked.expiredMessage") }}</p>
|
||||
</section>
|
||||
<router-view v-else />
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
@@ -352,6 +387,10 @@ async function handleLogout(): Promise<void> {
|
||||
margin-left: 250px;
|
||||
}
|
||||
|
||||
.admin-main-layout--blocked {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.admin-brand {
|
||||
height: 40px;
|
||||
background: #f0f8ff;
|
||||
@@ -384,6 +423,10 @@ async function handleLogout(): Promise<void> {
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
}
|
||||
|
||||
.admin-header--blocked {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.admin-header-breadcrumb {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
@@ -436,6 +479,7 @@ async function handleLogout(): Promise<void> {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.admin-locale-switch {
|
||||
@@ -574,4 +618,45 @@ async function handleLogout(): Promise<void> {
|
||||
margin: 24px;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.admin-content--blocked {
|
||||
margin: 0;
|
||||
min-height: calc(100vh - 64px);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.membership-expired-state {
|
||||
min-height: calc(100vh - 64px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 18px;
|
||||
padding: 32px;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.membership-expired-logo {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 18px;
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
box-shadow: 0 14px 32px rgba(22, 119, 255, 0.18);
|
||||
}
|
||||
|
||||
.membership-expired-state p {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -118,6 +118,7 @@ import {
|
||||
clearStoredSession,
|
||||
getStoredAccessToken,
|
||||
getStoredRefreshToken,
|
||||
markStoredMembershipBlocked,
|
||||
readStoredSession,
|
||||
setStoredTokens,
|
||||
} from "./session";
|
||||
@@ -271,6 +272,23 @@ export const apiClient = createApiClient({
|
||||
},
|
||||
});
|
||||
|
||||
const membershipBlockedErrors = new Set([
|
||||
"trial_plan_expired",
|
||||
"subscription_required",
|
||||
"subscription_inactive",
|
||||
]);
|
||||
|
||||
apiClient.raw.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error instanceof ApiClientError && membershipBlockedErrors.has(error.message)) {
|
||||
markStoredMembershipBlocked(error.message);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export const authApi = {
|
||||
login(payload: LoginRequest) {
|
||||
return publicClient.post<LoginResponse, LoginRequest>("/api/auth/login", payload);
|
||||
|
||||
@@ -95,6 +95,36 @@ export function setStoredUser(user: UserInfo | null): SessionSnapshot {
|
||||
return updateStoredSession({ user });
|
||||
}
|
||||
|
||||
export function markStoredMembershipBlocked(reason = "subscription_inactive"): SessionSnapshot {
|
||||
const snapshot = readStoredSession();
|
||||
if (!snapshot.user) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const current = snapshot.user.membership;
|
||||
const nextUser: UserInfo = {
|
||||
...snapshot.user,
|
||||
membership: {
|
||||
plan_code: current?.plan_code ?? "",
|
||||
plan_name: current?.plan_name ?? "",
|
||||
status: "expired",
|
||||
blocked: true,
|
||||
blocked_reason: reason,
|
||||
start_at: current?.start_at ?? null,
|
||||
end_at: current?.end_at ?? null,
|
||||
article_generation_limit: current?.article_generation_limit ?? 0,
|
||||
article_quota_cycle: current?.article_quota_cycle ?? "lifetime",
|
||||
ai_points_monthly: current?.ai_points_monthly ?? 0,
|
||||
ai_point_base_chars: current?.ai_point_base_chars ?? 1000,
|
||||
company_limit: current?.company_limit ?? 0,
|
||||
image_storage_bytes: current?.image_storage_bytes ?? 0,
|
||||
contact_admin_on_expiry: current?.contact_admin_on_expiry ?? true,
|
||||
},
|
||||
};
|
||||
|
||||
return updateStoredSession({ user: nextUser });
|
||||
}
|
||||
|
||||
export function clearStoredSession(options: { notifyAuthExpired?: boolean } = {}): SessionSnapshot {
|
||||
if (hasStorage()) {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
|
||||
@@ -18,7 +18,7 @@ const router = createRouter({
|
||||
{
|
||||
path: "/membership-blocked",
|
||||
name: "membership-blocked",
|
||||
component: () => import("@/views/MembershipBlockedView.vue"),
|
||||
redirect: "/workspace",
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
@@ -261,22 +261,11 @@ router.beforeEach(async (to) => {
|
||||
};
|
||||
}
|
||||
|
||||
if (authStore.isAuthenticated && authStore.isMembershipBlocked && to.name !== "membership-blocked") {
|
||||
return { name: "membership-blocked" };
|
||||
}
|
||||
|
||||
if (to.name === "membership-blocked" && authStore.isAuthenticated && !authStore.isMembershipBlocked) {
|
||||
return { name: "workspace" };
|
||||
}
|
||||
|
||||
if (to.meta.requiresKol && !authStore.isActiveKol) {
|
||||
return { name: "workspace" };
|
||||
}
|
||||
|
||||
if (to.name === "login" && authStore.isAuthenticated) {
|
||||
if (authStore.isMembershipBlocked) {
|
||||
return { name: "membership-blocked" };
|
||||
}
|
||||
return { name: "workspace" };
|
||||
}
|
||||
|
||||
|
||||
@@ -160,7 +160,16 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
|
||||
const isAuthenticated = computed(() => Boolean(accessToken.value && user.value));
|
||||
const membership = computed(() => user.value?.membership ?? null);
|
||||
const isMembershipBlocked = computed(() => Boolean(membership.value?.blocked));
|
||||
const isMembershipBlocked = computed(() => {
|
||||
const value = membership.value;
|
||||
return Boolean(
|
||||
value?.blocked ||
|
||||
value?.status === "expired" ||
|
||||
value?.blocked_reason === "trial_plan_expired" ||
|
||||
value?.blocked_reason === "subscription_required" ||
|
||||
value?.blocked_reason === "subscription_inactive",
|
||||
);
|
||||
});
|
||||
const kolProfile = computed(() => user.value?.kol_profile ?? null);
|
||||
const isActiveKol = computed(() => kolProfile.value?.status === "active");
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
</div>
|
||||
<form @submit.prevent="handleSubmit" class="form">
|
||||
<div class="field">
|
||||
<label for="login-email">{{ t('auth.email') }}</label>
|
||||
<input id="login-email" type="email" placeholder="admin@geo.local" v-model="formState.email"
|
||||
<label for="login-identifier">{{ t('auth.loginIdentifier') }}</label>
|
||||
<input id="login-identifier" type="text" placeholder="admin@geo.local / 13800138000" v-model="formState.identifier"
|
||||
autocomplete="off" @focus="isTyping = true" @blur="isTyping = false" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
@@ -98,7 +98,7 @@ const isTyping = ref(false);
|
||||
const remember = ref(false);
|
||||
|
||||
const formState = reactive({
|
||||
email: "admin@geo.local",
|
||||
identifier: "admin@geo.local",
|
||||
password: "Admin@123",
|
||||
});
|
||||
|
||||
|
||||
@@ -41,8 +41,6 @@ const expiryText = computed(() => {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user