fix(external-admin): resolve branding after login
This commit is contained in:
parent
e9c4595f02
commit
6ff2feb052
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#ffffff" />
|
<meta name="theme-color" content="#ffffff" />
|
||||||
<title>Fami</title>
|
<title>External Admin</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="external-admin-root"></div>
|
<div id="external-admin-root"></div>
|
||||||
|
|||||||
@ -16,7 +16,7 @@ describe("ExternalI18nProvider", () => {
|
|||||||
render(<ExternalI18nProvider><LocaleProbe /></ExternalI18nProvider>);
|
render(<ExternalI18nProvider><LocaleProbe /></ExternalI18nProvider>);
|
||||||
|
|
||||||
expect(screen.getByTestId("locale")).toHaveTextContent("en");
|
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("lang", "en");
|
||||||
expect(document.documentElement).toHaveAttribute("dir", "ltr");
|
expect(document.documentElement).toHaveAttribute("dir", "ltr");
|
||||||
});
|
});
|
||||||
@ -26,25 +26,25 @@ describe("ExternalI18nProvider", () => {
|
|||||||
const firstRender = render(<ExternalI18nProvider><LocaleProbe /></ExternalI18nProvider>);
|
const firstRender = render(<ExternalI18nProvider><LocaleProbe /></ExternalI18nProvider>);
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "ar" }));
|
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("lang", "ar");
|
||||||
expect(document.documentElement).toHaveAttribute("dir", "rtl");
|
expect(document.documentElement).toHaveAttribute("dir", "rtl");
|
||||||
expect(document.body).toHaveAttribute("dir", "rtl");
|
expect(document.body).toHaveAttribute("dir", "rtl");
|
||||||
expect(localStorage.getItem(EXTERNAL_LOCALE_STORAGE_KEY)).toBe("ar");
|
expect(localStorage.getItem(EXTERNAL_LOCALE_STORAGE_KEY)).toBe("ar");
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "tr" }));
|
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("lang", "tr");
|
||||||
expect(document.documentElement).toHaveAttribute("dir", "ltr");
|
expect(document.documentElement).toHaveAttribute("dir", "ltr");
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "zh-CN" }));
|
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");
|
expect(localStorage.getItem(EXTERNAL_LOCALE_STORAGE_KEY)).toBe("zh-CN");
|
||||||
|
|
||||||
firstRender.unmount();
|
firstRender.unmount();
|
||||||
render(<ExternalI18nProvider><LocaleProbe /></ExternalI18nProvider>);
|
render(<ExternalI18nProvider><LocaleProbe /></ExternalI18nProvider>);
|
||||||
expect(screen.getByTestId("locale")).toHaveTextContent("zh-CN");
|
expect(screen.getByTestId("locale")).toHaveTextContent("zh-CN");
|
||||||
expect(screen.getByTestId("title")).toHaveTextContent("Fami");
|
expect(screen.getByTestId("title")).toHaveTextContent("外管后台");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -10,8 +10,8 @@ export const EXTERNAL_LANGUAGE_OPTIONS = Object.freeze([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const en = {
|
const en = {
|
||||||
"app.title": "Fami",
|
"app.title": "External Admin",
|
||||||
"app.shortTitle": "Fami",
|
"app.shortTitle": "External Admin",
|
||||||
"language.label": "Language",
|
"language.label": "Language",
|
||||||
"common.actions": "Actions",
|
"common.actions": "Actions",
|
||||||
"common.active": "Active",
|
"common.active": "Active",
|
||||||
@ -321,8 +321,8 @@ const en = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const zhCN = {
|
const zhCN = {
|
||||||
"app.title": "Fami",
|
"app.title": "外管后台",
|
||||||
"app.shortTitle": "Fami",
|
"app.shortTitle": "外管后台",
|
||||||
"language.label": "语言",
|
"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": "用户",
|
"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": "显示密码",
|
"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 = {
|
const ar = {
|
||||||
...en,
|
...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": "المستخدم",
|
"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": "إظهار كلمة المرور",
|
"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": "فريقي",
|
"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 = {
|
const tr = {
|
||||||
...en,
|
...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ı",
|
"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",
|
"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",
|
"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",
|
||||||
|
|||||||
@ -13,10 +13,12 @@ describe("external admin translations", () => {
|
|||||||
|
|
||||||
test("uses native language names and Arabic RTL only", () => {
|
test("uses native language names and Arabic RTL only", () => {
|
||||||
expect(EXTERNAL_LANGUAGE_OPTIONS.map((item) => item.label)).toEqual(["English", "简体中文", "العربية", "Türkçe"]);
|
expect(EXTERNAL_LANGUAGE_OPTIONS.map((item) => item.label)).toEqual(["English", "简体中文", "العربية", "Türkçe"]);
|
||||||
for (const locale of ["en", "zh-CN", "ar", "tr"]) {
|
expect(["en", "zh-CN", "ar", "tr"].map((locale) => externalMessage(locale, "app.title"))).toEqual([
|
||||||
expect(externalMessage(locale, "app.title")).toBe("Fami");
|
"External Admin", "外管后台", "الإدارة الخارجية", "Harici Yönetim"
|
||||||
expect(externalMessage(locale, "app.shortTitle")).toBe("Fami");
|
]);
|
||||||
}
|
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("ar")).toBe("rtl");
|
||||||
expect(externalDirection("en")).toBe("ltr");
|
expect(externalDirection("en")).toBe("ltr");
|
||||||
expect(externalDirection("zh-CN")).toBe("ltr");
|
expect(externalDirection("zh-CN")).toBe("ltr");
|
||||||
|
|||||||
@ -27,13 +27,14 @@ import Tooltip from "@mui/material/Tooltip";
|
|||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
import { useTheme } from "@mui/material/styles";
|
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 { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
|
import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
|
||||||
import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx";
|
import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx";
|
||||||
import { hasAnyExternalCapability } from "../config/capabilities.js";
|
import { hasAnyExternalCapability } from "../config/capabilities.js";
|
||||||
import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx";
|
import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx";
|
||||||
import { LanguageSwitcher } from "../i18n/LanguageSwitcher.jsx";
|
import { LanguageSwitcher } from "../i18n/LanguageSwitcher.jsx";
|
||||||
|
import { useExternalSessionBrand } from "../shared/useExternalSessionBrand.js";
|
||||||
|
|
||||||
const drawerWidth = 248;
|
const drawerWidth = 248;
|
||||||
const navigation = [
|
const navigation = [
|
||||||
@ -64,19 +65,7 @@ export function ExternalAdminLayout() {
|
|||||||
[session]
|
[session]
|
||||||
);
|
);
|
||||||
const current = [...visibleNavigation].reverse().find((item) => location.pathname.startsWith(item.path));
|
const current = [...visibleNavigation].reverse().find((item) => location.pathname.startsWith(item.path));
|
||||||
|
useExternalSessionBrand(session, t("app.title"));
|
||||||
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]);
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout();
|
await logout();
|
||||||
@ -157,19 +146,3 @@ export function ExternalAdminLayout() {
|
|||||||
</Box>
|
</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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -69,7 +69,7 @@ describe("ExternalAdminLayout RTL", () => {
|
|||||||
expect(screen.getByRole("combobox", { name: "اللغة" })).toHaveTextContent("العربية");
|
expect(screen.getByRole("combobox", { name: "اللغة" })).toHaveTextContent("العربية");
|
||||||
|
|
||||||
view.unmount();
|
view.unmount();
|
||||||
expect(document.title).toBe("Fami");
|
expect(document.title).toBe("الإدارة الخارجية");
|
||||||
expect(document.head.querySelector('link[data-external-app-favicon="true"]')).not.toBeInTheDocument();
|
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");
|
expect(document.head.querySelector('link[data-external-app-favicon="true"]')).toHaveAttribute("href", "https://media.example.com/apps/lalu.png");
|
||||||
|
|
||||||
view.unmount();
|
view.unmount();
|
||||||
expect(document.title).toBe("Fami");
|
expect(document.title).toBe("الإدارة الخارجية");
|
||||||
expect(document.head.querySelector('link[data-external-app-favicon="true"]')).not.toBeInTheDocument();
|
expect(document.head.querySelector('link[data-external-app-favicon="true"]')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
import KeyOutlined from "@mui/icons-material/KeyOutlined";
|
|
||||||
import Alert from "@mui/material/Alert";
|
import Alert from "@mui/material/Alert";
|
||||||
import Avatar from "@mui/material/Avatar";
|
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import Card from "@mui/material/Card";
|
import Card from "@mui/material/Card";
|
||||||
@ -10,10 +8,12 @@ import TextField from "@mui/material/TextField";
|
|||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
|
||||||
import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx";
|
import { useExternalAuth } from "../auth/ExternalAuthProvider.jsx";
|
||||||
import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx";
|
import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx";
|
||||||
import { LanguageSwitcher } from "../i18n/LanguageSwitcher.jsx";
|
import { LanguageSwitcher } from "../i18n/LanguageSwitcher.jsx";
|
||||||
import { translateExternalError } from "../i18n/errors.js";
|
import { translateExternalError } from "../i18n/errors.js";
|
||||||
|
import { useExternalSessionBrand } from "../shared/useExternalSessionBrand.js";
|
||||||
|
|
||||||
export default function ChangePasswordPage() {
|
export default function ChangePasswordPage() {
|
||||||
const { changePassword, logout, session } = useExternalAuth();
|
const { changePassword, logout, session } = useExternalAuth();
|
||||||
@ -22,6 +22,7 @@ export default function ChangePasswordPage() {
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
useExternalSessionBrand(session, t("app.title"));
|
||||||
|
|
||||||
const submit = async (event) => {
|
const submit = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@ -59,7 +60,18 @@ export default function ChangePasswordPage() {
|
|||||||
<Card className="external-login-card" elevation={0}>
|
<Card className="external-login-card" elevation={0}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Stack sx={{ alignItems: "flex-end" }}><LanguageSwitcher /></Stack>
|
<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}>
|
<Stack component="form" spacing={2} onSubmit={submit}>
|
||||||
{error ? <Alert severity="error">{error}</Alert> : null}
|
{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 })} />
|
<TextField autoComplete="current-password" disabled={submitting} label={t("auth.currentPassword")} required type="password" value={form.oldPassword} onChange={(event) => setForm({ ...form, oldPassword: event.target.value })} />
|
||||||
|
|||||||
@ -7,7 +7,13 @@ import ChangePasswordPage from "./ChangePasswordPage.jsx";
|
|||||||
const auth = vi.hoisted(() => ({
|
const auth = vi.hoisted(() => ({
|
||||||
changePassword: vi.fn(),
|
changePassword: vi.fn(),
|
||||||
logout: 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 }));
|
vi.mock("../auth/ExternalAuthProvider.jsx", () => ({ useExternalAuth: () => auth }));
|
||||||
@ -16,6 +22,29 @@ describe("ChangePasswordPage", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
auth.changePassword.mockReset().mockResolvedValue({});
|
auth.changePassword.mockReset().mockResolvedValue({});
|
||||||
auth.logout.mockReset().mockResolvedValue(undefined);
|
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 () => {
|
test("blocks a whitespace-only new password", async () => {
|
||||||
|
|||||||
@ -28,6 +28,12 @@ export default function LoginPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
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(() => {
|
useEffect(() => {
|
||||||
if (!initializing && session) {
|
if (!initializing && session) {
|
||||||
const destination = session.passwordChangeRequired ? "/change-password" : location.state?.from?.pathname || "/overview";
|
const destination = session.passwordChangeRequired ? "/change-password" : location.state?.from?.pathname || "/overview";
|
||||||
|
|||||||
@ -18,12 +18,22 @@ describe("LoginPage", () => {
|
|||||||
|
|
||||||
test("defaults to English, has no App field and submits only account and password", async () => {
|
test("defaults to English, has no App field and submits only account and password", async () => {
|
||||||
const user = userEvent.setup();
|
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();
|
const { container } = renderLogin();
|
||||||
|
|
||||||
expect(screen.getByRole("heading", { name: "Fami" })).toBeInTheDocument();
|
expect(screen.getByRole("heading", { name: "External Admin" })).toBeInTheDocument();
|
||||||
expect(container.querySelector("img")).toBeNull();
|
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();
|
expect(screen.queryByRole("combobox", { name: "App" })).not.toBeInTheDocument();
|
||||||
await user.type(screen.getByRole("textbox", { name: "External admin account" }), "fami-manager");
|
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.type(container.querySelector('input[autocomplete="current-password"]'), "secret-password");
|
||||||
await user.click(screen.getByRole("button", { name: "Sign in" }));
|
await user.click(screen.getByRole("button", { name: "Sign in" }));
|
||||||
|
|
||||||
|
|||||||
32
external-admin/src/shared/useExternalSessionBrand.js
Normal file
32
external-admin/src/shared/useExternalSessionBrand.js
Normal 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 品牌只采用服务端 session;session 又来自 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -35,9 +35,9 @@ body {
|
|||||||
min-width: 140px !important;
|
min-width: 140px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.external-login-avatar {
|
.external-change-password-brand {
|
||||||
background: var(--brand-gradient) !important;
|
font-size: 18px;
|
||||||
color: #fff;
|
font-weight: 750;
|
||||||
}
|
}
|
||||||
|
|
||||||
.external-full-state {
|
.external-full-state {
|
||||||
|
|||||||
@ -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.getByText("Fami")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("F", { selector: "span" })).not.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();
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user