fix(external-admin): resolve branding after login

This commit is contained in:
zhx 2026-07-15 19:03:07 +08:00
parent e9c4595f02
commit 6ff2feb052
13 changed files with 134 additions and 56 deletions

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#ffffff" />
<title>Fami</title>
<title>External Admin</title>
</head>
<body>
<div id="external-admin-root"></div>

View File

@ -16,7 +16,7 @@ describe("ExternalI18nProvider", () => {
render(<ExternalI18nProvider><LocaleProbe /></ExternalI18nProvider>);
expect(screen.getByTestId("locale")).toHaveTextContent("en");
expect(screen.getByTestId("title")).toHaveTextContent("Fami");
expect(screen.getByTestId("title")).toHaveTextContent("External Admin");
expect(document.documentElement).toHaveAttribute("lang", "en");
expect(document.documentElement).toHaveAttribute("dir", "ltr");
});
@ -26,25 +26,25 @@ describe("ExternalI18nProvider", () => {
const firstRender = render(<ExternalI18nProvider><LocaleProbe /></ExternalI18nProvider>);
await user.click(screen.getByRole("button", { name: "ar" }));
expect(screen.getByTestId("title")).toHaveTextContent("Fami");
expect(screen.getByTestId("title")).toHaveTextContent("الإدارة الخارجية");
expect(document.documentElement).toHaveAttribute("lang", "ar");
expect(document.documentElement).toHaveAttribute("dir", "rtl");
expect(document.body).toHaveAttribute("dir", "rtl");
expect(localStorage.getItem(EXTERNAL_LOCALE_STORAGE_KEY)).toBe("ar");
await user.click(screen.getByRole("button", { name: "tr" }));
expect(screen.getByTestId("title")).toHaveTextContent("Fami");
expect(screen.getByTestId("title")).toHaveTextContent("Harici Yönetim");
expect(document.documentElement).toHaveAttribute("lang", "tr");
expect(document.documentElement).toHaveAttribute("dir", "ltr");
await user.click(screen.getByRole("button", { name: "zh-CN" }));
expect(screen.getByTestId("title")).toHaveTextContent("Fami");
expect(screen.getByTestId("title")).toHaveTextContent("外管后台");
expect(localStorage.getItem(EXTERNAL_LOCALE_STORAGE_KEY)).toBe("zh-CN");
firstRender.unmount();
render(<ExternalI18nProvider><LocaleProbe /></ExternalI18nProvider>);
expect(screen.getByTestId("locale")).toHaveTextContent("zh-CN");
expect(screen.getByTestId("title")).toHaveTextContent("Fami");
expect(screen.getByTestId("title")).toHaveTextContent("外管后台");
});
});

View File

@ -10,8 +10,8 @@ export const EXTERNAL_LANGUAGE_OPTIONS = Object.freeze([
]);
const en = {
"app.title": "Fami",
"app.shortTitle": "Fami",
"app.title": "External Admin",
"app.shortTitle": "External Admin",
"language.label": "Language",
"common.actions": "Actions",
"common.active": "Active",
@ -321,8 +321,8 @@ const en = {
};
const zhCN = {
"app.title": "Fami",
"app.shortTitle": "Fami",
"app.title": "外管后台",
"app.shortTitle": "外管后台",
"language.label": "语言",
"common.actions": "操作", "common.active": "正常", "common.all": "全部", "common.allStatuses": "全部状态", "common.banned": "已封禁", "common.cancel": "取消", "common.confirm": "确认", "common.create": "创建", "common.disabled": "禁用", "common.edit": "编辑", "common.enabled": "启用", "common.expired": "过期", "common.loading": "正在加载", "common.noData": "当前无数据", "common.normal": "正常", "common.query": "查询", "common.reason": "原因", "common.reload": "重新加载", "common.retry": "重试", "common.save": "保存", "common.status": "状态", "common.user": "用户",
"auth.account": "外管账号", "auth.changePassword": "修改外管密码", "auth.checkingSession": "正在校验外管会话", "auth.confirmNewPassword": "确认新密码", "auth.currentPassword": "当前密码", "auth.fillAccountPassword": "请填写账号和密码", "auth.firstLoginChange": "首次登录必须修改密码", "auth.hidePassword": "隐藏密码", "auth.invalidCredentials": "账号或密码错误", "auth.login": "登录", "auth.loginFailed": "登录失败", "auth.logout": "退出登录", "auth.newPassword": "新密码", "auth.noPermission": "当前外管账号没有访问此功能的权限。", "auth.password": "密码", "auth.passwordChangedFailed": "密码修改失败", "auth.passwordMismatch": "两次输入的新密码不一致", "auth.passwordMin": "至少 8 个字符", "auth.passwordMinError": "新密码至少 8 个字符", "auth.passwordWhitespace": "新密码不能为空或全为空白字符", "auth.recheck": "重新校验", "auth.returnOverview": "返回权限总览", "auth.savePassword": "保存密码", "auth.sessionFailed": "会话校验失败", "auth.showPassword": "显示密码",
@ -345,7 +345,7 @@ const zhCN = {
const ar = {
...en,
"app.title": "Fami", "app.shortTitle": "Fami", "language.label": "اللغة",
"app.title": "الإدارة الخارجية", "app.shortTitle": "الإدارة الخارجية", "language.label": "اللغة",
"common.actions": "الإجراءات", "common.active": "نشط", "common.all": "الكل", "common.allStatuses": "كل الحالات", "common.banned": "محظور", "common.cancel": "إلغاء", "common.confirm": "تأكيد", "common.create": "إنشاء", "common.disabled": "معطّل", "common.edit": "تعديل", "common.enabled": "مفعّل", "common.expired": "منتهي", "common.loading": "جارٍ التحميل", "common.noData": "لا توجد بيانات", "common.normal": "طبيعي", "common.query": "بحث", "common.reason": "السبب", "common.reload": "إعادة التحميل", "common.retry": "إعادة المحاولة", "common.save": "حفظ", "common.status": "الحالة", "common.user": "المستخدم",
"auth.account": "حساب الإدارة الخارجية", "auth.changePassword": "تغيير كلمة مرور الإدارة الخارجية", "auth.checkingSession": "جارٍ التحقق من الجلسة", "auth.confirmNewPassword": "تأكيد كلمة المرور الجديدة", "auth.currentPassword": "كلمة المرور الحالية", "auth.fillAccountPassword": "أدخل الحساب وكلمة المرور", "auth.firstLoginChange": "يجب تغيير كلمة المرور عند تسجيل الدخول لأول مرة", "auth.hidePassword": "إخفاء كلمة المرور", "auth.invalidCredentials": "الحساب أو كلمة المرور غير صحيحة", "auth.login": "تسجيل الدخول", "auth.loginFailed": "فشل تسجيل الدخول", "auth.logout": "تسجيل الخروج", "auth.newPassword": "كلمة المرور الجديدة", "auth.noPermission": "لا يملك هذا الحساب صلاحية الوصول إلى هذه الميزة.", "auth.password": "كلمة المرور", "auth.passwordChangedFailed": "فشل تغيير كلمة المرور", "auth.passwordMismatch": "كلمتا المرور الجديدتان غير متطابقتين", "auth.passwordMin": "8 أحرف على الأقل", "auth.passwordMinError": "يجب أن تتكون كلمة المرور الجديدة من 8 أحرف على الأقل", "auth.passwordWhitespace": "لا يمكن أن تكون كلمة المرور الجديدة فارغة أو مسافات فقط", "auth.recheck": "إعادة التحقق", "auth.returnOverview": "العودة إلى ملخص الصلاحيات", "auth.savePassword": "حفظ كلمة المرور", "auth.sessionFailed": "فشل التحقق من الجلسة", "auth.showPassword": "إظهار كلمة المرور",
"nav.expand": "فتح القائمة", "nav.overview": "ملخص الصلاحيات", "nav.users": "إدارة المستخدمين", "nav.bans": "إدارة الحظر", "nav.hosts": "المضيفون", "nav.agencies": "الوكالات", "nav.bds": "قائمة BD", "nav.bdManagers": "مديرو BD", "nav.superAdmins": "المشرفون العامون", "nav.rooms": "إدارة الغرف", "nav.grants": "الموارد والمعرّفات المميزة", "nav.banners": "إدارة اللافتات", "nav.team": "فريقي",
@ -367,7 +367,7 @@ const ar = {
const tr = {
...en,
"app.title": "Fami", "app.shortTitle": "Fami", "language.label": "Dil",
"app.title": "Harici Yönetim", "app.shortTitle": "Harici Yönetim", "language.label": "Dil",
"common.actions": "İşlemler", "common.active": "Aktif", "common.all": "Tümü", "common.allStatuses": "Tüm durumlar", "common.banned": "Yasaklı", "common.cancel": "İptal", "common.confirm": "Onayla", "common.create": "Oluştur", "common.disabled": "Devre dışı", "common.edit": "Düzenle", "common.enabled": "Etkin", "common.expired": "Süresi dolmuş", "common.loading": "Yükleniyor", "common.noData": "Veri yok", "common.normal": "Normal", "common.query": "Ara", "common.reason": "Neden", "common.reload": "Yeniden yükle", "common.retry": "Tekrar dene", "common.save": "Kaydet", "common.status": "Durum", "common.user": "Kullanıcı",
"auth.account": "Harici yönetim hesabı", "auth.changePassword": "Harici yönetim parolasını değiştir", "auth.checkingSession": "Oturum kontrol ediliyor", "auth.confirmNewPassword": "Yeni parolayı doğrula", "auth.currentPassword": "Mevcut parola", "auth.fillAccountPassword": "Hesabınızı ve parolanızı girin", "auth.firstLoginChange": "İlk girişte parolanızı değiştirmeniz gerekir", "auth.hidePassword": "Parolayı gizle", "auth.invalidCredentials": "Hesap veya parola hatalı", "auth.login": "Giriş yap", "auth.loginFailed": "Giriş başarısız", "auth.logout": ıkış yap", "auth.newPassword": "Yeni parola", "auth.noPermission": "Bu harici yönetim hesabının bu özelliğe erişimi yok.", "auth.password": "Parola", "auth.passwordChangedFailed": "Parola değiştirilemedi", "auth.passwordMismatch": "Yeni parolalar eşleşmiyor", "auth.passwordMin": "En az 8 karakter", "auth.passwordMinError": "Yeni parola en az 8 karakter olmalıdır", "auth.passwordWhitespace": "Yeni parola boş veya yalnızca boşluk olamaz", "auth.recheck": "Tekrar kontrol et", "auth.returnOverview": "Yetki özetine dön", "auth.savePassword": "Parolayı kaydet", "auth.sessionFailed": "Oturum doğrulanamadı", "auth.showPassword": "Parolayı göster",
"nav.expand": "Menüyü aç", "nav.overview": "Yetki özeti", "nav.users": "Kullanıcı yönetimi", "nav.bans": "Yasak yönetimi", "nav.hosts": "Yayıncılar", "nav.agencies": "Ajanslar", "nav.bds": "BD listesi", "nav.bdManagers": "BD Yöneticileri", "nav.superAdmins": "Süper Yöneticiler", "nav.rooms": "Oda yönetimi", "nav.grants": "Kaynaklar ve özel ID'ler", "nav.banners": "Banner yönetimi", "nav.team": "Ekibim",

View File

@ -13,10 +13,12 @@ describe("external admin translations", () => {
test("uses native language names and Arabic RTL only", () => {
expect(EXTERNAL_LANGUAGE_OPTIONS.map((item) => item.label)).toEqual(["English", "简体中文", "العربية", "Türkçe"]);
for (const locale of ["en", "zh-CN", "ar", "tr"]) {
expect(externalMessage(locale, "app.title")).toBe("Fami");
expect(externalMessage(locale, "app.shortTitle")).toBe("Fami");
}
expect(["en", "zh-CN", "ar", "tr"].map((locale) => externalMessage(locale, "app.title"))).toEqual([
"External Admin", "外管后台", "الإدارة الخارجية", "Harici Yönetim"
]);
expect(["en", "zh-CN", "ar", "tr"].map((locale) => externalMessage(locale, "app.shortTitle"))).toEqual([
"External Admin", "外管后台", "الإدارة الخارجية", "Harici Yönetim"
]);
expect(externalDirection("ar")).toBe("rtl");
expect(externalDirection("en")).toBe("ltr");
expect(externalDirection("zh-CN")).toBe("ltr");

View File

@ -27,13 +27,14 @@ import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx";
import { hasAnyExternalCapability } from "../config/capabilities.js";
import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx";
import { LanguageSwitcher } from "../i18n/LanguageSwitcher.jsx";
import { useExternalSessionBrand } from "../shared/useExternalSessionBrand.js";
const drawerWidth = 248;
const navigation = [
@ -64,19 +65,7 @@ export function ExternalAdminLayout() {
[session]
);
const current = [...visibleNavigation].reverse().find((item) => location.pathname.startsWith(item.path));
useEffect(() => {
// Login resolves the account's App server-side. Both the visible brand and browser metadata must use that apps row rather than a bundled image or client-selected tenant.
const appName = String(session?.appName || session?.appCode || t("app.title")).trim();
const logoUrl = String(session?.logoUrl || "").trim();
globalThis.document.title = appName;
syncBrandFavicon(logoUrl);
return () => {
// Leaving an authenticated App session must not leak its brand into the next account's login screen.
globalThis.document.title = t("app.title");
syncBrandFavicon("");
};
}, [session?.appCode, session?.appName, session?.logoUrl, t]);
useExternalSessionBrand(session, t("app.title"));
const handleLogout = async () => {
await logout();
@ -157,19 +146,3 @@ export function ExternalAdminLayout() {
</Box>
);
}
function syncBrandFavicon(logoUrl) {
const selector = 'link[data-external-app-favicon="true"]';
const current = globalThis.document?.head?.querySelector(selector);
if (!logoUrl) {
current?.remove();
return;
}
const link = current || globalThis.document.createElement("link");
link.dataset.externalAppFavicon = "true";
link.rel = "icon";
link.href = logoUrl;
if (!current) {
globalThis.document.head.append(link);
}
}

View File

@ -69,7 +69,7 @@ describe("ExternalAdminLayout RTL", () => {
expect(screen.getByRole("combobox", { name: "اللغة" })).toHaveTextContent("العربية");
view.unmount();
expect(document.title).toBe("Fami");
expect(document.title).toBe("الإدارة الخارجية");
expect(document.head.querySelector('link[data-external-app-favicon="true"]')).not.toBeInTheDocument();
});
@ -89,7 +89,7 @@ describe("ExternalAdminLayout RTL", () => {
expect(document.head.querySelector('link[data-external-app-favicon="true"]')).toHaveAttribute("href", "https://media.example.com/apps/lalu.png");
view.unmount();
expect(document.title).toBe("Fami");
expect(document.title).toBe("الإدارة الخارجية");
expect(document.head.querySelector('link[data-external-app-favicon="true"]')).not.toBeInTheDocument();
});
});

View File

@ -1,6 +1,4 @@
import KeyOutlined from "@mui/icons-material/KeyOutlined";
import Alert from "@mui/material/Alert";
import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Card from "@mui/material/Card";
@ -10,10 +8,12 @@ import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx";
import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx";
import { LanguageSwitcher } from "../i18n/LanguageSwitcher.jsx";
import { translateExternalError } from "../i18n/errors.js";
import { useExternalSessionBrand } from "../shared/useExternalSessionBrand.js";
export default function ChangePasswordPage() {
const { changePassword, logout, session } = useExternalAuth();
@ -22,6 +22,7 @@ export default function ChangePasswordPage() {
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const navigate = useNavigate();
useExternalSessionBrand(session, t("app.title"));
const submit = async (event) => {
event.preventDefault();
@ -59,7 +60,18 @@ export default function ChangePasswordPage() {
<Card className="external-login-card" elevation={0}>
<CardContent>
<Stack sx={{ alignItems: "flex-end" }}><LanguageSwitcher /></Stack>
<Stack spacing={1.5} sx={{ alignItems: "center" }}><Avatar className="external-login-avatar"><KeyOutlined /></Avatar><Typography component="h1" variant="h5">{t("auth.changePassword")}</Typography><Typography color="text.secondary">{session?.passwordChangeRequired ? t("auth.firstLoginChange") : session?.account}</Typography></Stack>
<Stack spacing={1.5} sx={{ alignItems: "center" }}>
<AppIdentity
appCode={session?.appCode}
appName={session?.appName || session?.appCode}
className="external-change-password-brand"
logoUrl={session?.logoUrl}
showLogoFallback={false}
size="large"
/>
<Typography component="h1" variant="h5">{t("auth.changePassword")}</Typography>
<Typography color="text.secondary">{session?.passwordChangeRequired ? t("auth.firstLoginChange") : session?.account}</Typography>
</Stack>
<Stack component="form" spacing={2} onSubmit={submit}>
{error ? <Alert severity="error">{error}</Alert> : null}
<TextField autoComplete="current-password" disabled={submitting} label={t("auth.currentPassword")} required type="password" value={form.oldPassword} onChange={(event) => setForm({ ...form, oldPassword: event.target.value })} />

View File

@ -7,7 +7,13 @@ import ChangePasswordPage from "./ChangePasswordPage.jsx";
const auth = vi.hoisted(() => ({
changePassword: vi.fn(),
logout: vi.fn(),
session: { account: "fami-manager", passwordChangeRequired: true }
session: {
account: "fami-manager",
appCode: "fami",
appName: "Fami",
logoUrl: "https://media.example.com/apps/fami.png",
passwordChangeRequired: true
}
}));
vi.mock("../auth/ExternalAuthProvider.jsx", () => ({ useExternalAuth: () => auth }));
@ -16,6 +22,29 @@ describe("ChangePasswordPage", () => {
beforeEach(() => {
auth.changePassword.mockReset().mockResolvedValue({});
auth.logout.mockReset().mockResolvedValue(undefined);
auth.session = {
account: "fami-manager",
appCode: "fami",
appName: "Fami",
logoUrl: "https://media.example.com/apps/fami.png",
passwordChangeRequired: true
};
});
test("shows the App brand resolved by the authenticated session", () => {
auth.session = {
...auth.session,
account: "lalu-manager",
appCode: "lalu",
appName: "Lalu",
logoUrl: "https://media.example.com/apps/lalu.png"
};
const view = render(<MemoryRouter><ChangePasswordPage /></MemoryRouter>);
expect(screen.getByText("Lalu")).toBeInTheDocument();
expect(view.container.querySelector("img")).toHaveAttribute("src", "https://media.example.com/apps/lalu.png");
expect(document.title).toBe("Lalu");
expect(document.head.querySelector('link[data-external-app-favicon="true"]')).toHaveAttribute("href", "https://media.example.com/apps/lalu.png");
});
test("blocks a whitespace-only new password", async () => {

View File

@ -28,6 +28,12 @@ export default function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
// App 使 App
globalThis.document.title = t("app.title");
globalThis.document.head.querySelector('link[data-external-app-favicon="true"]')?.remove();
}, [t]);
useEffect(() => {
if (!initializing && session) {
const destination = session.passwordChangeRequired ? "/change-password" : location.state?.from?.pathname || "/overview";

View File

@ -18,12 +18,22 @@ describe("LoginPage", () => {
test("defaults to English, has no App field and submits only account and password", async () => {
const user = userEvent.setup();
const staleFavicon = document.createElement("link");
staleFavicon.dataset.externalAppFavicon = "true";
staleFavicon.rel = "icon";
staleFavicon.href = "https://media.example.com/apps/lalu.png";
document.head.append(staleFavicon);
document.title = "Lalu";
const { container } = renderLogin();
expect(screen.getByRole("heading", { name: "Fami" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "External Admin" })).toBeInTheDocument();
expect(container.querySelector("img")).toBeNull();
expect(document.title).toBe("External Admin");
expect(document.head.querySelector('link[data-external-app-favicon="true"]')).not.toBeInTheDocument();
expect(screen.queryByRole("combobox", { name: "App" })).not.toBeInTheDocument();
await user.type(screen.getByRole("textbox", { name: "External admin account" }), "fami-manager");
expect(screen.getByRole("heading", { name: "External Admin" })).toBeInTheDocument();
expect(container.querySelector("img")).toBeNull();
await user.type(container.querySelector('input[autocomplete="current-password"]'), "secret-password");
await user.click(screen.getByRole("button", { name: "Sign in" }));

View File

@ -0,0 +1,32 @@
import { useEffect } from "react";
export function useExternalSessionBrand(session, fallbackTitle) {
const appName = String(session?.appName || session?.appCode || fallbackTitle || "External Admin").trim();
const logoUrl = String(session?.logoUrl || "").trim();
useEffect(() => {
// 认证后的 App 品牌只采用服务端 sessionsession 又来自 apps 行,前端不解析账号名称猜租户。
globalThis.document.title = appName;
syncBrandFavicon(logoUrl);
return () => {
globalThis.document.title = fallbackTitle || "External Admin";
syncBrandFavicon("");
};
}, [appName, fallbackTitle, logoUrl]);
}
function syncBrandFavicon(logoUrl) {
const selector = 'link[data-external-app-favicon="true"]';
const current = globalThis.document?.head?.querySelector(selector);
if (!logoUrl) {
current?.remove();
return;
}
const link = current || globalThis.document.createElement("link");
link.dataset.externalAppFavicon = "true";
link.rel = "icon";
link.href = logoUrl;
if (!current) {
globalThis.document.head.append(link);
}
}

View File

@ -35,9 +35,9 @@ body {
min-width: 140px !important;
}
.external-login-avatar {
background: var(--brand-gradient) !important;
color: #fff;
.external-change-password-brand {
font-size: 18px;
font-weight: 750;
}
.external-full-state {

View File

@ -39,3 +39,17 @@ test("can hide the fallback when a surface must only show a database logo", () =
expect(screen.getByText("Fami")).toBeInTheDocument();
expect(screen.queryByText("F", { selector: "span" })).not.toBeInTheDocument();
});
test("does not invent a fallback when an external portal database logo fails to load", () => {
const { container } = render(
<AppIdentity
app={{ appCode: "fami", appName: "Fami", logoUrl: "https://media.example.com/apps/missing-fami.png" }}
showLogoFallback={false}
/>,
);
fireEvent.error(container.querySelector("img"));
expect(container.querySelector("img")).toBeNull();
expect(screen.getByText("Fami")).toBeInTheDocument();
expect(screen.queryByText("F", { selector: "span" })).not.toBeInTheDocument();
});