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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user