keycloakify-starter 亮色改为雾白、冰蓝工作区,不再大面积深色。

暗色使用深海蓝灰,并保持足够的层级和对比度。
class 主题同样支持亮暗模式。
Account/Admin 页头增加模式切换按钮,选择会持久保存。
仍遵守 Keycloak 的 darkMode=false 配置,关闭时只允许亮色。
Account 统一为“页面上下文 → 身份业务区 → 操作区”结构。
Admin 统一为“领域上下文 → 业务内容 → 固定操作区”结构。
表格、表单、详情、向导、弹窗、空状态、安全设备及应用授权组件全部纳入统一设计系统。
移动端自动收起侧栏、双栏变单栏,并调整固定操作区。
This commit is contained in:
2026-08-04 17:08:35 +08:00 Unverified
parent 676c73c82b
commit ec277d4e3f
14 changed files with 1021 additions and 192 deletions
+5 -5
View File
@@ -1,17 +1,22 @@
# This file is managed by Keycloakify, do not edit it manually.
# === Owned files start ===
# account/colorScheme.ts
# account/KcAccountUi.tsx
# account/KcContext.ts
# account/KcPage.tsx
# admin/App.tsx
# admin/colorScheme.ts
# admin/index.css
# admin/KcAdminUi.tsx
# admin/page-nav.css
# admin/PageHeader.tsx
# admin/PageNav.tsx
# account/assets/logo.svg
# account/personal-info/PersonalInfo.tsx
# account/root/Header.tsx
# account/root/PageNav.tsx
# account/root/Root.tsx
# admin/assets/logo.svg
# admin/dashboard/dashboard.css
# admin/dashboard/Dashboard.tsx
@@ -91,7 +96,6 @@
/admin/admin-client.tsx
/admin/banners.module.css
/admin/Banners.tsx
/admin/colorScheme.ts
/admin/environment-types.ts
/admin/environment.ts
/admin/ForbiddenSection.tsx
@@ -99,7 +103,6 @@
/admin/i18next.d.ts
/admin/KcContext.ts
/admin/KcPage.tsx
/admin/PageHeader.tsx
/admin/PageHeaderClearCachesModal.tsx
/admin/PageNotFoundSection.tsx
/admin/Root.tsx
@@ -841,7 +844,6 @@
# === @keycloakify/keycloak-account-ui v260700.0.3 ===
/account/api.ts
/account/colorScheme.ts
/account/environment.ts
/account/i18n-type.ts
/account/i18next.d.ts
@@ -915,8 +917,6 @@
/account/resources/SharedWith.tsx
/account/resources/ShareTheResource.tsx
/account/root/header.module.css
/account/root/Header.tsx
/account/root/Root.tsx
/account/utils/formatDate.ts
/account/utils/joinPath.ts
/account/utils/useAccountAlerts.ts
+21
View File
@@ -0,0 +1,21 @@
/**
* This file has been claimed for ownership from @keycloakify/keycloak-account-ui version 260700.0.3.
* To relinquish ownership and restore this file to its original content, run the following command:
*
* $ npx keycloakify own --path "account/colorScheme.ts" --revert
*/
/* IMPORTANT NOTE:
* If you modify how the light/dark mode is implemented
* you must also update public/admin/early-color-scheme.js
* This scrip is ran before this to avoid white flashes
*/
import { getKcContext } from "./KcContext";
import { startConsoleColorScheme } from "../shared/consoleColorScheme";
export function startColorSchemeManagement() {
const { kcContext } = getKcContext();
startConsoleColorScheme(kcContext.darkMode !== false);
}
+13 -11
View File
@@ -24,12 +24,12 @@ type PageProps = {
export const Page = ({ title, description, children }: PropsWithChildren<PageProps>) => {
return (
<>
<PageSection variant="light">
<div className="nexus-page-intro">
<div className="nexus-page-intro__copy">
<span className="nexus-page-intro__kicker">
NEXUS / Identity control
<article className="account-business-page">
<PageSection variant="light" className="account-view-header">
<div className="account-view-header__layout">
<div className="account-view-header__copy">
<span className="account-view-header__eyebrow">
Identity workspace / Account
</span>
<TextContent>
<Title headingLevel="h1" data-testid="page-heading">
@@ -38,14 +38,16 @@ export const Page = ({ title, description, children }: PropsWithChildren<PagePro
<Text component="p">{description}</Text>
</TextContent>
</div>
<div className="nexus-page-intro__mark" aria-hidden="true">
<span>ID</span>
<div className="account-view-header__context" aria-hidden="true">
<span>SELF SERVICE</span>
<strong>ID</strong>
<i />
<span>ACCOUNT</span>
</div>
</div>
</PageSection>
<PageSection variant="light">{children}</PageSection>
</>
<PageSection variant="light" className="account-view-body">
<div className="account-view-body__surface">{children}</div>
</PageSection>
</article>
);
};
+73
View File
@@ -0,0 +1,73 @@
/**
* This file has been claimed for ownership from @keycloakify/keycloak-account-ui version 260700.0.3.
* To relinquish ownership and restore this file to its original content, run the following command:
*
* $ npx keycloakify own --path "account/root/Header.tsx" --revert
*/
/* eslint-disable */
// @ts-nocheck
import logoSvgUrl from "../assets/logo.svg";
import { KeycloakMasthead, label, useEnvironment } from "../../shared/keycloak-ui-shared";
import { Button } from "../../shared/@patternfly/react-core";
import { ExternalLinkSquareAltIcon } from "../../shared/@patternfly/react-icons";
import { useTranslation } from "react-i18next";
import { useHref } from "react-router-dom";
import { AccountEnvironment } from "..";
import { joinPath } from "../utils/joinPath";
import { ThemeModeToggle } from "../../shared/ThemeModeToggle";
import style from "./header.module.css";
const ReferrerLink = () => {
const { environment } = useEnvironment<AccountEnvironment>();
const { t } = useTranslation();
return environment.referrerUrl ? (
<Button
data-testid="referrer-link"
component="a"
href={environment.referrerUrl.replace("_hash_", "#")}
variant="link"
icon={<ExternalLinkSquareAltIcon />}
iconPosition="right"
isInline
>
{t("backTo", {
app: label(t, environment.referrerName, environment.referrerUrl)
})}
</Button>
) : null;
};
export const Header = () => {
const { environment, keycloak } = useEnvironment();
const { t } = useTranslation();
const logoUrl = environment.logoUrl ? environment.logoUrl : "/";
const internalLogoHref = useHref(logoUrl);
// User can indicate that he wants an internal URL by starting it with "/"
const indexHref = logoUrl.startsWith("/") ? internalLogoHref : logoUrl;
return (
<KeycloakMasthead
data-testid="page-header"
keycloak={keycloak}
features={{ hasManageAccount: false }}
brand={{
href: indexHref,
src: logoSvgUrl,
alt: t("logo"),
className: style.brand
}}
toolbarItems={[
<ThemeModeToggle key="theme-mode" />,
<ReferrerLink key="link" />
]}
/>
);
};
+113
View File
@@ -0,0 +1,113 @@
/**
* This file has been claimed for ownership from @keycloakify/keycloak-account-ui version 260700.0.3.
* To relinquish ownership and restore this file to its original content, run the following command:
*
* $ npx keycloakify own --path "account/root/Root.tsx" --revert
*/
/* eslint-disable */
// @ts-nocheck
import {
ErrorPage,
useAlerts,
useEnvironment,
KeycloakContext
} from "../../shared/keycloak-ui-shared";
import { AlertVariant, Page, Spinner } from "../../shared/@patternfly/react-core";
import { Suspense, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
createBrowserRouter,
Navigate,
Outlet,
RouteObject,
RouterProvider
} from "react-router-dom";
import fetchContentJson from "../content/fetchContent";
import { type AccountEnvironment } from "..";
import { usePromise } from "../utils/usePromise";
import { Header } from "./Header";
import { MenuItem, PageNav } from "./PageNav";
import { routes } from "../routes";
function mapRoutes(
context: KeycloakContext<AccountEnvironment>,
content: MenuItem[]
): RouteObject[] {
return content
.map(item => {
if ("children" in item) {
return mapRoutes(context, item.children);
}
// Do not add route disabled via feature flags
if (item.isVisible && !context.environment.features[item.isVisible]) {
return null;
}
return {
...item,
element:
"path" in item
? routes.find(r => r.path === (item.id ?? item.path))?.element
: undefined
};
})
.filter(item => !!item)
.flat();
}
function CatchAllRedirect() {
const { t } = useTranslation();
const { addAlert } = useAlerts();
useEffect(() => {
addAlert(t("pageNotFound"), AlertVariant.warning);
}, [addAlert, t]);
return <Navigate to="." replace />;
}
export const Root = () => {
const context = useEnvironment<AccountEnvironment>();
const [content, setContent] = useState<RouteObject[]>();
usePromise(
signal => fetchContentJson({ signal, context }),
content => {
setContent([
{
path: decodeURIComponent(
new URL(context.environment.baseUrl).pathname
),
element: (
<Page header={<Header />} sidebar={<PageNav />} isManagedSidebar>
<Suspense fallback={<Spinner />}>
<div
className="account-workspace-route"
data-workspace="personal-identity"
>
<div className="account-workspace-route__content">
<Outlet />
</div>
</div>
</Suspense>
</Page>
),
errorElement: <ErrorPage />,
children: [
...mapRoutes(context, content),
{ path: "*", element: <CatchAllRedirect /> }
]
}
]);
}
);
if (!content) {
return <Spinner />;
}
return <RouterProvider router={createBrowserRouter(content)} />;
};
+220 -139
View File
@@ -1,7 +1,15 @@
/* Account-specific composition on top of the shared console language. */
/* Account workspace: self-service identity tasks share one business-page skeleton. */
.account-workspace-route,
.account-workspace-route__content,
.account-business-page {
min-width: 0;
min-height: 100%;
}
.nexus-nav-context {
gap: 0.25rem;
display: grid;
gap: 0.28rem;
margin: 0 0.35rem 1.25rem;
padding: 0.15rem 0.45rem 1.15rem;
border-bottom: 1px solid var(--console-line);
@@ -9,122 +17,255 @@
.nexus-nav-context__eyebrow {
color: var(--console-primary-deep);
font-family: inherit;
font-size: 0.65rem;
font-weight: 750;
letter-spacing: 0.11em;
font-size: 0.64rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.nexus-nav-context strong {
color: var(--console-ink);
font-family: inherit;
font-size: 1.05rem;
font-weight: 720;
font-weight: 740;
}
.nexus-nav-context__rule {
width: 4.25rem;
width: 4.5rem;
height: 3px;
margin-top: 0.45rem;
margin-top: 0.48rem;
border-radius: 99px;
background: linear-gradient(
90deg,
var(--console-teal) 0 34%,
var(--console-amber) 34% 67%,
var(--console-coral) 67%
);
opacity: 1;
mask-image: none;
}
.nexus-nav-label {
display: grid;
grid-template-columns: 1.6rem minmax(0, 1fr);
align-items: center;
gap: 0.68rem;
}
.nexus-nav-label__icon {
display: grid;
width: 1.6rem;
height: 1.6rem;
border: 1px solid var(--console-line);
border-radius: 7px;
color: var(--console-faint);
background: color-mix(in srgb, var(--console-surface) 58%, transparent);
place-items: center;
}
.pf-v5-c-nav__link:hover .nexus-nav-label__icon,
.pf-v5-c-nav__link:focus .nexus-nav-label__icon,
.pf-v5-c-nav__link.pf-m-current .nexus-nav-label__icon {
color: var(--console-primary);
transform: none;
border-color: color-mix(in srgb, var(--console-primary) 30%, var(--console-line));
color: var(--console-primary-deep);
background: color-mix(in srgb, var(--console-primary) 9%, var(--console-surface));
}
.pf-v5-c-page__main-section:first-child {
padding-top: clamp(2rem, 4vw, 3.5rem);
padding-bottom: clamp(1.5rem, 2.5vw, 2.25rem);
border-bottom: 0;
.account-view-header {
position: relative;
padding-top: clamp(1.9rem, 4vw, 3.7rem);
padding-bottom: clamp(1.5rem, 2.8vw, 2.4rem);
border: 0;
background: transparent;
backdrop-filter: none;
}
.pf-v5-c-page__main-section:first-child::after {
display: none;
.account-view-header::before {
position: absolute;
top: 0;
bottom: 0;
left: clamp(1.25rem, 3.2vw, 3.75rem);
width: 3px;
border-radius: 99px;
background: linear-gradient(
180deg,
var(--console-teal) 0 34%,
var(--console-amber) 34% 67%,
var(--console-coral) 67%
);
content: "";
}
.nexus-page-intro {
align-items: center;
.account-view-header__layout {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 2rem;
padding-left: 1.25rem;
}
.nexus-page-intro__kicker {
margin-bottom: 0.65rem;
.account-view-header__copy {
min-width: 0;
}
.account-view-header__eyebrow {
display: block;
margin-bottom: 0.62rem;
color: var(--console-primary-deep);
font-family: inherit;
font-size: 0.7rem;
font-weight: 750;
letter-spacing: 0.11em;
}
.nexus-page-intro__mark {
grid-template-columns: auto 3.5rem auto;
gap: 0.65rem;
padding: 0.65rem 0.85rem;
border: 1px solid var(--console-line);
border-radius: 999px;
color: var(--console-muted);
background: color-mix(in srgb, var(--console-surface) 76%, transparent);
font-family: inherit;
font-size: 0.62rem;
font-weight: 700;
box-shadow: 0 8px 22px rgba(23, 50, 77, 0.055);
}
.nexus-page-intro__mark > span:first-child {
color: var(--console-primary-deep);
font-family: inherit;
font-size: 0.72rem;
font-size: 0.67rem;
font-weight: 800;
letter-spacing: 0.13em;
text-transform: uppercase;
}
.nexus-page-intro__mark i {
.account-view-header h1 {
margin: 0;
color: var(--console-ink);
font-size: clamp(2.15rem, 3.6vw, 3.35rem);
font-weight: 740;
letter-spacing: -0.05em;
line-height: 1.02;
}
.account-view-header p {
max-width: 72ch;
margin: 0.85rem 0 0;
color: var(--console-muted);
line-height: 1.7;
}
.account-view-header__context {
display: grid;
grid-template-columns: auto auto;
align-items: center;
gap: 0.4rem 0.8rem;
min-width: 156px;
padding: 0.9rem 1rem;
border: 1px solid var(--console-line);
border-radius: 12px;
color: var(--console-muted);
background: color-mix(in srgb, var(--console-surface) 78%, transparent);
box-shadow: var(--console-shadow);
}
.account-view-header__context span {
font-size: 0.59rem;
font-weight: 800;
letter-spacing: 0.1em;
}
.account-view-header__context strong {
grid-row: span 2;
color: var(--console-primary-deep);
font-size: 1.45rem;
letter-spacing: -0.06em;
}
.account-view-header__context i {
width: 100%;
height: 3px;
border-radius: 99px;
background: linear-gradient(90deg, var(--console-teal), var(--console-primary));
}
.pf-v5-c-page__main-section + .pf-v5-c-page__main-section {
.account-view-body {
padding-top: 0;
padding-bottom: 5rem;
padding-bottom: clamp(3.5rem, 7vw, 6rem);
background: transparent;
}
.account-view-body__surface {
min-width: 0;
}
/* Lists, security devices, applications and permissions become operational decks. */
.account-view-body__surface > .pf-v5-c-data-list,
.account-view-body__surface > .pf-v5-c-table,
.account-view-body__surface > .pf-v5-c-card,
.account-view-body__surface > .pf-v5-c-stack,
.account-view-body__surface > .pf-v5-c-form,
.account-view-body__surface > form {
width: 100%;
}
.account-view-body .pf-v5-c-data-list__item-row {
min-height: 72px;
align-items: center;
}
.account-view-body .pf-v5-c-data-list__cell {
color: var(--console-muted);
line-height: 1.55;
}
.account-view-body .pf-v5-c-data-list__cell strong,
.account-view-body .pf-v5-c-data-list__cell b,
.account-view-body .pf-v5-c-data-list__cell h2,
.account-view-body .pf-v5-c-data-list__cell h3 {
color: var(--console-ink);
}
.account-view-body .pf-v5-c-stack > .pf-v5-c-stack__item + .pf-v5-c-stack__item {
margin-top: 1.25rem;
}
.account-view-body .pf-v5-c-title.pf-m-xl,
.account-view-body .pf-v5-c-title.pf-m-lg {
margin-bottom: 0.85rem;
padding-bottom: 0.72rem;
border-bottom: 1px solid var(--console-line);
color: var(--console-ink);
}
.account-view-body .pf-v5-c-form {
max-width: 1120px;
}
.account-view-body .pf-v5-c-form__group {
padding-block: 0.28rem;
}
.account-view-body .pf-v5-c-form__actions {
position: sticky;
bottom: 0;
z-index: 4;
margin-top: 2rem;
padding: 1rem;
border: 1px solid var(--console-line);
border-radius: 12px;
background: color-mix(in srgb, var(--console-surface) 92%, transparent);
box-shadow: 0 -10px 28px color-mix(in srgb, var(--console-ink) 8%, transparent);
backdrop-filter: blur(16px);
}
/* Personal information is a two-column identity record, not a generic form. */
.nexus-profile-layout {
display: grid;
grid-template-columns: minmax(245px, 0.58fr) minmax(0, 1.65fr);
align-items: start;
gap: clamp(1.25rem, 2.6vw, 2.5rem);
}
.nexus-identity-card,
.nexus-profile-editor {
position: relative;
overflow: hidden;
border: 1px solid var(--console-line);
border-radius: var(--console-radius);
color: var(--console-ink);
background: var(--console-surface);
box-shadow: var(--console-shadow);
}
.nexus-identity-card {
min-height: 0;
padding: 1.45rem;
border: 1px solid var(--console-line);
border-radius: var(--console-radius);
color: var(--console-ink);
background: linear-gradient(
145deg,
var(--console-surface),
var(--console-surface-soft)
);
box-shadow: var(--console-shadow);
}
.nexus-identity-card::before {
position: absolute;
inset: 0 0 auto;
width: auto;
height: 4px;
background: linear-gradient(
90deg,
@@ -135,21 +276,12 @@
content: "";
}
.nexus-identity-card::after {
right: -4.5rem;
bottom: -5.5rem;
border-color: color-mix(in srgb, var(--console-primary) 10%, transparent);
box-shadow:
0 0 0 2.8rem color-mix(in srgb, var(--console-primary) 3%, transparent),
0 0 0 5.6rem color-mix(in srgb, var(--console-teal) 2%, transparent);
}
.nexus-identity-card__topline,
.nexus-identity-card__name > span,
.nexus-identity-card__facts dt,
.nexus-identity-card__footer {
.nexus-identity-card__footer,
.nexus-identity-card__name p {
color: var(--console-muted);
font-family: inherit;
font-weight: 700;
}
@@ -158,7 +290,6 @@
border-radius: 50%;
background: var(--console-primary);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--console-primary) 12%, transparent);
transform: none;
}
.nexus-identity-card__avatar {
@@ -170,14 +301,11 @@
color: #ffffff;
background: linear-gradient(145deg, var(--console-primary), var(--console-teal));
box-shadow: 0 10px 24px color-mix(in srgb, var(--console-primary) 22%, transparent);
transform: none;
}
.nexus-identity-card__avatar span {
font-family: inherit;
font-size: 1.35rem;
font-weight: 760;
transform: none;
font-weight: 780;
}
.nexus-identity-card__name h2,
@@ -186,27 +314,14 @@
}
.nexus-identity-card__name h2 {
font-family: inherit;
font-size: 1.55rem;
letter-spacing: -0.03em;
}
.nexus-identity-card__name p {
color: var(--console-muted);
}
.nexus-identity-card__facts > div {
border-top-color: var(--console-line);
}
.nexus-profile-editor {
overflow: hidden;
border: 1px solid var(--console-line);
border-radius: var(--console-radius);
background: var(--console-surface);
box-shadow: var(--console-shadow);
}
.nexus-profile-editor__header {
padding: 1.55rem clamp(1.25rem, 2.5vw, 2rem);
border-bottom: 1px solid var(--console-line);
@@ -219,13 +334,11 @@
.nexus-profile-editor__header > span {
color: var(--console-primary-deep);
font-family: inherit;
font-weight: 750;
font-weight: 780;
}
.nexus-profile-editor__header h2 {
color: var(--console-ink);
font-family: inherit;
font-size: 1.28rem;
}
@@ -237,65 +350,33 @@
padding: clamp(1.35rem, 3vw, 2.2rem);
}
.nexus-profile-editor .pf-v5-c-form__group {
padding-block: 0.45rem;
}
html[data-kc-theme-name="class"] .nexus-identity-card,
html[data-kc-theme-name="class"] .nexus-profile-editor,
html[data-kc-theme-name="class"] .pf-v5-c-card {
border-radius: var(--console-radius);
}
html[data-kc-theme-name="class"] .nexus-identity-card {
border-color: var(--console-line);
color: var(--console-ink);
background: linear-gradient(
145deg,
var(--console-surface),
var(--console-surface-soft)
);
box-shadow: var(--console-shadow);
}
html[data-kc-theme-name="class"] .nexus-identity-card::after {
display: block;
}
html[data-kc-theme-name="class"] .nexus-identity-card__topline,
html[data-kc-theme-name="class"] .nexus-identity-card__name p,
html[data-kc-theme-name="class"] .nexus-identity-card__facts dt,
html[data-kc-theme-name="class"] .nexus-identity-card__footer {
color: var(--console-muted);
}
html[data-kc-theme-name="class"] .nexus-identity-card__facts dd {
color: var(--console-ink);
}
html[data-kc-theme-name="class"] .nexus-identity-card__avatar {
border: 0;
border-radius: 18px;
color: #ffffff;
background: linear-gradient(145deg, var(--console-primary), var(--console-teal));
box-shadow: 0 10px 24px color-mix(in srgb, var(--console-primary) 22%, transparent);
}
@media (max-width: 768px) {
.nexus-page-intro__mark {
.account-view-header::before {
left: 1rem;
}
.account-view-header__layout {
grid-template-columns: 1fr;
padding-left: 1rem;
}
.account-view-header__context {
display: none;
}
.account-view-header h1 {
font-size: 2.15rem;
}
.nexus-profile-layout {
grid-template-columns: 1fr;
}
.nexus-identity-card {
position: relative;
top: auto;
.account-view-body .pf-v5-c-data-list__item-row {
align-items: start;
}
.nexus-profile-editor__header {
grid-template-columns: 1fr;
.account-view-body .pf-v5-c-form__actions {
position: static;
}
}
+7 -2
View File
@@ -81,8 +81,13 @@ export const App = () => {
<ErrorBoundaryFallback fallback={ErrorRenderer}>
<Suspense fallback={<KeycloakSpinner />}>
<AuthWall>
<div className="admin-workspace-route">
<Outlet />
<div
className="admin-workspace-route"
data-workspace="realm-operations"
>
<div className="admin-workspace-route__content">
<Outlet />
</div>
</div>
</AuthWall>
</Suspense>
+150
View File
@@ -0,0 +1,150 @@
/**
* This file has been claimed for ownership from @keycloakify/keycloak-admin-ui version 260700.0.2.
* To relinquish ownership and restore this file to its original content, run the following command:
*
* $ npx keycloakify own --path "admin/PageHeader.tsx" --revert
*/
/* eslint-disable */
// @ts-nocheck
import logoSvgUrl from "./assets/logo.svg";
import { KeycloakMasthead, useEnvironment, useHelp } from "../shared/keycloak-ui-shared";
import { DropdownItem, ToolbarItem } from "../shared/@patternfly/react-core";
import { HelpIcon } from "../shared/@patternfly/react-icons";
import { useTranslation } from "react-i18next";
import { Link, useHref } from "react-router-dom";
import { PageHeaderClearCachesModal } from "./PageHeaderClearCachesModal";
import { HelpHeader } from "./components/help-enabler/HelpHeader";
import { useAccess } from "./context/access/Access";
import { useRealm } from "./context/realm-context/RealmContext";
import { toDashboard } from "./dashboard/routes/Dashboard";
import type { Environment } from "./environment-types";
import { usePreviewLogo } from "./realm-settings/themes/LogoContext";
import { joinPath } from "./utils/joinPath";
import { useIsFeatureDisabled, Feature } from "./utils/useIsFeatureEnabled";
import useToggle from "./utils/useToggle";
import { ThemeModeToggle } from "../shared/ThemeModeToggle";
const ManageAccountDropdownItem = () => {
const { keycloak } = useEnvironment();
const { t } = useTranslation();
const isFeatureDisabled = useIsFeatureDisabled();
if (isFeatureDisabled(Feature.AccountV3)) {
return null;
}
return (
<DropdownItem
key="manage account"
id="manage-account"
onClick={() => keycloak.accountManagement()}
>
{t("manageAccount")}
</DropdownItem>
);
};
const ServerInfoDropdownItem = () => {
const { realm } = useRealm();
const { t } = useTranslation();
return (
<DropdownItem
key="server info"
component={props => <Link {...props} to={toDashboard({ realm })} />}
>
{t("realmInfo")}
</DropdownItem>
);
};
const ClearCachesDropdownItem = () => {
const { t } = useTranslation();
const [open, toggleModal] = useToggle();
return (
<>
<DropdownItem key="clear caches" onClick={() => toggleModal()}>
{t("clearCachesTitle")}
</DropdownItem>
{open && <PageHeaderClearCachesModal onClose={() => toggleModal()} />}
</>
);
};
const HelpDropdownItem = () => {
const { t } = useTranslation();
const { enabled, toggleHelp } = useHelp();
return (
<DropdownItem data-testId="helpIcon" icon={<HelpIcon />} onClick={toggleHelp}>
{enabled ? t("helpEnabled") : t("helpDisabled")}
</DropdownItem>
);
};
const kebabDropdownItems = (isMasterRealm: boolean, isManager: boolean) => [
<ManageAccountDropdownItem key="kebab Manage Account" />,
<ServerInfoDropdownItem key="kebab Server Info" />,
...(isMasterRealm && isManager
? [<ClearCachesDropdownItem key="Clear Caches" />]
: []),
<HelpDropdownItem key="kebab Help" />
];
const userDropdownItems = (isMasterRealm: boolean, isManager: boolean) => [
<ManageAccountDropdownItem key="Manage Account" />,
<ServerInfoDropdownItem key="Server info" />,
...(isMasterRealm && isManager
? [<ClearCachesDropdownItem key="Clear Caches" />]
: [])
];
export const Header = () => {
const { environment, keycloak } = useEnvironment<Environment>();
const { t } = useTranslation();
const { realm } = useRealm();
const { hasAccess } = useAccess();
const contextLogo = usePreviewLogo();
const customLogo = contextLogo?.logo;
const isMasterRealm = realm === environment.masterRealm;
const isManager = hasAccess("manage-realm");
const url = useHref(toDashboard({ realm }));
const logoUrl = environment.logoUrl ? environment.logoUrl : url;
return (
<KeycloakMasthead
data-testid="page-header"
keycloak={keycloak}
features={{ hasManageAccount: false }}
brand={{
href: logoUrl,
src: customLogo?.trim() ? customLogo : logoSvgUrl,
alt: t("logo"),
className: "keycloak__pageheader_brand"
}}
dropdownItems={userDropdownItems(isMasterRealm, isManager)}
kebabDropdownItems={kebabDropdownItems(isMasterRealm, isManager)}
toolbarItems={[
<ToolbarItem key="theme-mode">
<ThemeModeToggle />
</ToolbarItem>,
<ToolbarItem
key="help"
align={{ default: "alignRight" }}
visibility={{
default: "hidden",
md: "visible"
}} /** the settings and help icon buttons are only visible on desktop sizes and replaced by a kebab dropdown for other sizes */
>
<HelpHeader />
</ToolbarItem>
]}
/>
);
};
+21
View File
@@ -0,0 +1,21 @@
/**
* This file has been claimed for ownership from @keycloakify/keycloak-admin-ui version 260700.0.2.
* To relinquish ownership and restore this file to its original content, run the following command:
*
* $ npx keycloakify own --path "admin/colorScheme.ts" --revert
*/
/* IMPORTANT NOTE:
* If you modify how the light/dark mode is implemented
* you must also update public/admin/early-color-scheme.js
* This scrip is ran before this to avoid white flashes
*/
import { getKcContext } from "./KcContext";
import { startConsoleColorScheme } from "../shared/consoleColorScheme";
export function startColorSchemeManagement() {
const { kcContext } = getKcContext();
startConsoleColorScheme(kcContext.darkMode !== false);
}
+56 -16
View File
@@ -17,16 +17,21 @@
min-height: 260px;
border: 1px solid var(--console-line);
border-radius: 20px;
color: #f7fbff;
background: linear-gradient(135deg, #193a5b, #245a89 58%, #277e78);
box-shadow: 0 24px 52px rgba(23, 50, 77, 0.16);
color: var(--console-ink);
background: linear-gradient(
135deg,
var(--console-surface) 0%,
color-mix(in srgb, var(--console-primary) 8%, var(--console-surface)) 58%,
color-mix(in srgb, var(--console-teal) 12%, var(--console-surface)) 100%
);
box-shadow: var(--console-shadow);
}
.admin-dashboard-hero::before {
position: absolute;
inset: 0;
background-image: linear-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
background-image: linear-gradient(var(--console-line) 1px, transparent 1px),
linear-gradient(90deg, var(--console-line) 1px, transparent 1px);
background-size: 48px 48px;
content: "";
mask-image: linear-gradient(90deg, transparent, #000 65%);
@@ -65,12 +70,12 @@
}
.admin-dashboard-eyebrow {
color: #92e3cf;
color: var(--console-teal);
}
.admin-dashboard-hero h1 {
margin: 0;
color: #ffffff;
color: var(--console-ink);
font-size: clamp(2.4rem, 5vw, 4.8rem);
font-weight: 740;
letter-spacing: -0.055em;
@@ -80,7 +85,7 @@
.admin-dashboard-hero__copy > p {
max-width: 60ch;
margin: 1.25rem 0 1.6rem;
color: rgba(235, 246, 255, 0.78);
color: var(--console-muted);
font-size: 1rem;
line-height: 1.7;
}
@@ -90,18 +95,18 @@
flex-wrap: wrap;
align-items: center;
gap: 0.65rem 0.9rem;
color: rgba(235, 246, 255, 0.62);
color: var(--console-muted);
font-size: 0.8rem;
}
.admin-dashboard-hero__meta strong {
color: #ffffff;
color: var(--console-ink);
font-family: "Cascadia Code", Consolas, monospace;
font-weight: 600;
}
.admin-dashboard-hero .admin-dashboard-status {
color: rgba(235, 246, 255, 0.82);
color: var(--console-muted);
}
.admin-dashboard-hero__signal {
@@ -111,12 +116,12 @@
align-content: center;
justify-items: end;
padding: clamp(1.5rem, 3vw, 2.5rem);
border-left: 1px solid rgba(255, 255, 255, 0.13);
border-left: 1px solid var(--console-line);
text-align: right;
}
.admin-dashboard-hero__signal > span {
color: rgba(235, 246, 255, 0.48);
color: var(--console-faint);
font-size: 0.64rem;
font-weight: 800;
letter-spacing: 0.18em;
@@ -126,11 +131,11 @@
width: 100%;
height: 1px;
margin: 0.75rem 0 1rem;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.38));
background: linear-gradient(90deg, transparent, var(--console-line-strong));
}
.admin-dashboard-hero__signal strong {
color: #ffffff;
color: var(--console-primary-deep);
font-size: clamp(3.5rem, 7vw, 6.5rem);
font-weight: 300;
letter-spacing: -0.08em;
@@ -139,7 +144,7 @@
.admin-dashboard-hero__signal small {
margin-top: 0.75rem;
color: rgba(235, 246, 255, 0.66);
color: var(--console-muted);
}
.admin-dashboard-layout {
@@ -361,6 +366,41 @@
height: 100%;
}
.pf-v5-theme-dark .admin-dashboard-hero {
color: #edf6fb;
background: linear-gradient(135deg, #112d43, #173f5e 58%, #1c625e);
box-shadow: 0 24px 52px rgba(0, 0, 0, 0.28);
}
.pf-v5-theme-dark .admin-dashboard-hero::before {
background-image: linear-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
}
.pf-v5-theme-dark .admin-dashboard-eyebrow {
color: #83dcc5;
}
.pf-v5-theme-dark .admin-dashboard-hero h1,
.pf-v5-theme-dark .admin-dashboard-hero__meta strong,
.pf-v5-theme-dark .admin-dashboard-hero__signal strong {
color: #ffffff;
}
.pf-v5-theme-dark .admin-dashboard-hero__copy > p,
.pf-v5-theme-dark .admin-dashboard-hero .admin-dashboard-status {
color: rgba(235, 246, 255, 0.8);
}
.pf-v5-theme-dark .admin-dashboard-hero__meta,
.pf-v5-theme-dark .admin-dashboard-hero__signal small {
color: rgba(235, 246, 255, 0.64);
}
.pf-v5-theme-dark .admin-dashboard-hero__signal {
border-left-color: rgba(255, 255, 255, 0.13);
}
@media (max-width: 1050px) {
.admin-dashboard-layout {
grid-template-columns: 1fr;
+86
View File
@@ -3,6 +3,11 @@
min-height: 100%;
}
.admin-workspace-route__content {
min-width: 0;
min-height: 100%;
}
.admin-view-header {
position: relative;
padding-top: clamp(1.7rem, 3vw, 2.7rem);
@@ -130,6 +135,82 @@
font-weight: 700;
}
/* Every administration route follows the same operational content grammar. */
.admin-workspace-route .pf-v5-c-page__main-section:not(.admin-view-header) {
color: var(--console-ink);
}
.admin-workspace-route .pf-v5-c-page__main-section:not(.admin-view-header) > form,
.admin-workspace-route
.pf-v5-c-page__main-section:not(.admin-view-header)
> .pf-v5-c-form,
.admin-workspace-route
.pf-v5-c-page__main-section:not(.admin-view-header)
> .keycloak__form {
width: 100%;
max-width: 1160px;
}
.admin-workspace-route .pf-v5-c-card + .pf-v5-c-card,
.admin-workspace-route .pf-v5-c-panel + .pf-v5-c-panel,
.admin-workspace-route .pf-v5-c-alert + .pf-v5-c-card {
margin-top: 1rem;
}
.admin-workspace-route .pf-v5-c-description-list {
padding: 1.15rem;
border: 1px solid var(--console-line);
border-radius: var(--console-radius);
background: var(--console-surface);
}
.admin-workspace-route .pf-v5-c-description-list__term {
color: var(--console-muted);
font-size: 0.76rem;
font-weight: 750;
letter-spacing: 0.035em;
}
.admin-workspace-route .pf-v5-c-description-list__description {
color: var(--console-ink);
}
.admin-workspace-route .pf-v5-c-form__actions,
.admin-workspace-route .keycloak__form_actions {
position: sticky;
bottom: 0;
z-index: 6;
margin-top: 2rem;
padding: 1rem;
border: 1px solid var(--console-line);
border-radius: 12px;
background: color-mix(in srgb, var(--console-surface) 92%, transparent);
box-shadow: 0 -10px 28px color-mix(in srgb, var(--console-ink) 8%, transparent);
backdrop-filter: blur(16px);
}
.admin-workspace-route .pf-v5-c-wizard {
overflow: hidden;
border: 1px solid var(--console-line);
border-radius: var(--console-radius);
background: var(--console-surface);
box-shadow: var(--console-shadow);
}
.admin-workspace-route .pf-v5-c-wizard__nav {
border-right-color: var(--console-line);
background: var(--console-surface-soft);
}
.admin-workspace-route .pf-v5-c-drawer__panel {
background: var(--console-surface-raised);
}
.admin-workspace-route .pf-v5-c-empty-state {
min-height: 260px;
place-content: center;
}
@media (max-width: 768px) {
.admin-view-header::before {
left: 1rem;
@@ -149,4 +230,9 @@
.admin-view-header h1 {
font-size: 2rem;
}
.admin-workspace-route .pf-v5-c-form__actions,
.admin-workspace-route .keycloak__form_actions {
position: static;
}
}
+117 -19
View File
@@ -5,20 +5,22 @@
*/
:root {
--console-ink: #17324d;
--console-muted: #667a8e;
--console-faint: #8da0b1;
--console-canvas: #f3f7fb;
--console-ink: #15334c;
--console-muted: #5f768a;
--console-faint: #8399aa;
--console-canvas: #edf5fa;
--console-surface: #ffffff;
--console-surface-soft: #f8fbfe;
--console-nav: #edf4fa;
--console-line: #d9e4ed;
--console-line-strong: #b9cbd9;
--console-primary: #356fd6;
--console-primary-deep: #2459b4;
--console-teal: #279477;
--console-amber: #e9ad38;
--console-coral: #e86452;
--console-surface-soft: #f6fafc;
--console-surface-raised: #ffffff;
--console-surface-sunken: #e7f0f6;
--console-nav: #e5f0f7;
--console-line: #d2e0e9;
--console-line-strong: #adc3d2;
--console-primary: #2f72d6;
--console-primary-deep: #1f58ad;
--console-teal: #1e9476;
--console-amber: #d99b25;
--console-coral: #dd5c4b;
--console-danger: #c94046;
--console-radius-sm: 8px;
--console-radius: 14px;
@@ -43,12 +45,14 @@
--console-ink: #eaf2f8;
--console-muted: #a4b5c4;
--console-faint: #8295a7;
--console-canvas: #0d1823;
--console-surface: #142332;
--console-surface-soft: #182a3a;
--console-nav: #101f2d;
--console-line: #294052;
--console-line-strong: #456176;
--console-canvas: #09151f;
--console-surface: #122331;
--console-surface-soft: #172b3a;
--console-surface-raised: #192d3d;
--console-surface-sunken: #0d1d29;
--console-nav: #0d1d29;
--console-line: #294354;
--console-line-strong: #49667a;
--console-primary: #78a8ff;
--console-primary-deep: #96baff;
--console-teal: #61c7aa;
@@ -75,6 +79,8 @@ html[data-kc-theme-name="class"] {
--console-muted: #61778c;
--console-canvas: #f4f9fd;
--console-nav: #edf6fc;
--console-surface-raised: #ffffff;
--console-surface-sunken: #e8f3fa;
--console-line: #d4e3ee;
--console-line-strong: #abc4d7;
--console-primary: #3f7fd6;
@@ -91,6 +97,8 @@ html[data-kc-theme-name="class"].pf-v5-theme-dark {
--console-surface: #102435;
--console-surface-soft: #152c3e;
--console-nav: #0d2132;
--console-surface-raised: #193246;
--console-surface-sunken: #0a1824;
--console-line: #2d485d;
--console-line-strong: #4d6c82;
--console-primary: #76aafa;
@@ -115,6 +123,16 @@ body {
font-size: 15px;
}
html[data-console-color-mode] body,
html[data-console-color-mode] .pf-v5-c-page,
html[data-console-color-mode] .pf-v5-c-page__sidebar,
html[data-console-color-mode] .pf-v5-c-masthead {
transition:
color 180ms ease,
background-color 180ms ease,
border-color 180ms ease;
}
.pf-v5-c-page,
.pf-v5-c-page__main,
.pf-v5-c-page__main-section,
@@ -190,6 +208,31 @@ img.keycloak__pageheader_brand {
background: var(--console-nav);
}
.console-theme-toggle.pf-v5-c-button {
display: inline-flex;
align-items: center;
gap: 0.48rem;
min-width: 5.15rem;
min-height: 38px;
padding: 0.45rem 0.7rem;
border: 1px solid var(--console-line);
border-radius: 999px;
color: var(--console-muted);
background: var(--console-surface-soft);
}
.console-theme-toggle.pf-v5-c-button:hover {
border-color: var(--console-line-strong);
color: var(--console-primary-deep);
background: var(--console-surface-raised);
}
.console-theme-toggle__label {
font-size: 0.75rem;
font-weight: 750;
letter-spacing: 0.02em;
}
/* Navigation is now a calm secondary surface instead of a competing dark panel. */
.pf-v5-c-page__sidebar,
.pf-v5-c-page__sidebar-body {
@@ -345,6 +388,24 @@ img.keycloak__pageheader_brand {
box-shadow: var(--console-shadow);
}
.pf-v5-c-accordion,
.pf-v5-c-panel,
.pf-v5-c-description-list,
.pf-v5-c-tree-view,
.pf-v5-c-dual-list-selector,
.pf-v5-c-wizard,
.pf-v5-c-code-block {
color: var(--console-ink);
}
.pf-v5-c-panel,
.pf-v5-c-wizard__main,
.pf-v5-c-drawer__content,
.pf-v5-c-code-block {
border-color: var(--console-line);
background: var(--console-surface);
}
.pf-v5-c-card,
.pf-v5-c-table,
.pf-v5-c-modal-box {
@@ -390,6 +451,10 @@ img.keycloak__pageheader_brand {
background: color-mix(in srgb, var(--console-primary) 4%, var(--console-surface));
}
.pf-v5-c-table tbody tr:nth-child(even) > * {
background: color-mix(in srgb, var(--console-surface-soft) 55%, transparent);
}
.pf-v5-c-toolbar__content {
padding-inline: 0;
}
@@ -459,6 +524,12 @@ img.keycloak__pageheader_brand {
border-bottom: 1px solid var(--console-line);
}
.pf-v5-c-tabs__scroll-button,
.pf-v5-c-tabs__toggle {
color: var(--console-muted);
background: var(--console-surface);
}
.pf-v5-c-tabs__link {
color: var(--console-muted);
font-weight: 650;
@@ -505,6 +576,24 @@ input[type="radio"] {
box-shadow: 0 -10px 30px rgba(23, 50, 77, 0.055);
}
.pf-v5-c-modal-box__body,
.pf-v5-c-modal-box__footer,
.pf-v5-c-menu__content,
.pf-v5-c-popover__body,
.pf-v5-c-tooltip__content {
color: var(--console-ink);
background: var(--console-surface);
}
.pf-v5-c-menu__item {
color: var(--console-ink);
}
.pf-v5-c-menu__item:hover,
.pf-v5-c-menu__item:focus {
background: var(--console-surface-sunken);
}
div.w-tc-editor {
border-color: var(--console-line);
border-radius: var(--console-radius-sm);
@@ -538,6 +627,15 @@ div.w-tc-editor {
height: 36px;
}
.console-theme-toggle.pf-v5-c-button {
min-width: 2.5rem;
padding-inline: 0.65rem;
}
.console-theme-toggle__label {
display: none;
}
.pf-v5-c-page__main-section,
.pf-v5-c-page__main-breadcrumb {
padding-inline: 1rem;
+51
View File
@@ -0,0 +1,51 @@
import { useEffect, useState } from "react";
import { Button } from "./@patternfly/react-core";
import { MoonIcon, SunIcon } from "./@patternfly/react-icons";
import {
getConsoleColorMode,
subscribeToConsoleColorMode,
toggleConsoleColorMode
} from "./consoleColorScheme";
function labels(isDark: boolean) {
const isChinese = document.documentElement.lang.toLowerCase().startsWith("zh");
if (isChinese) {
return {
current: isDark ? "暗色" : "亮色",
action: isDark ? "切换至亮色模式" : "切换至暗色模式"
};
}
return {
current: isDark ? "Dark" : "Light",
action: isDark ? "Switch to light mode" : "Switch to dark mode"
};
}
export function ThemeModeToggle() {
const [mode, setMode] = useState(getConsoleColorMode);
useEffect(() => subscribeToConsoleColorMode(setMode), []);
if (document.documentElement.dataset.consoleDarkAllowed === "false") {
return null;
}
const isDark = mode === "dark";
const copy = labels(isDark);
return (
<Button
variant="plain"
className="console-theme-toggle"
aria-label={copy.action}
title={copy.action}
aria-pressed={isDark}
icon={isDark ? <MoonIcon /> : <SunIcon />}
onClick={toggleConsoleColorMode}
>
<span className="console-theme-toggle__label">{copy.current}</span>
</Button>
);
}
+88
View File
@@ -0,0 +1,88 @@
export type ConsoleColorMode = "light" | "dark";
const STORAGE_KEY = "nexus-console-color-mode";
const DARK_THEME_CLASS = "pf-v5-theme-dark";
const COLOR_MODE_EVENT = "nexus:color-mode-change";
function readStoredMode(): ConsoleColorMode | undefined {
try {
const value = localStorage.getItem(STORAGE_KEY);
return value === "light" || value === "dark" ? value : undefined;
} catch {
return undefined;
}
}
function preferredMode(): ConsoleColorMode {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function getConsoleColorMode(): ConsoleColorMode {
return document.documentElement.classList.contains(DARK_THEME_CLASS)
? "dark"
: "light";
}
export function setConsoleColorMode(
mode: ConsoleColorMode,
options: { persist?: boolean } = {}
) {
const { persist = true } = options;
const isDark = mode === "dark";
const root = document.documentElement;
document.getElementById("root-color-scheme-style")?.remove();
const style = document.createElement("style");
style.id = "root-color-scheme-style";
style.textContent = `:root { color-scheme: ${mode}; }`;
document.head.appendChild(style);
root.style.removeProperty("background-color");
root.classList.toggle(DARK_THEME_CLASS, isDark);
root.dataset.consoleColorMode = mode;
if (persist) {
try {
localStorage.setItem(STORAGE_KEY, mode);
} catch {
// Storage can be unavailable in hardened browser sessions.
}
}
window.dispatchEvent(new CustomEvent(COLOR_MODE_EVENT, { detail: mode }));
}
export function startConsoleColorScheme(darkModeAllowed: boolean) {
const root = document.documentElement;
root.dataset.consoleDarkAllowed = String(darkModeAllowed);
if (!darkModeAllowed) {
setConsoleColorMode("light", { persist: false });
return;
}
setConsoleColorMode(readStoredMode() ?? preferredMode(), { persist: false });
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", event => {
if (!readStoredMode()) {
setConsoleColorMode(event.matches ? "dark" : "light", {
persist: false
});
}
});
}
export function toggleConsoleColorMode() {
setConsoleColorMode(getConsoleColorMode() === "dark" ? "light" : "dark");
}
export function subscribeToConsoleColorMode(listener: (mode: ConsoleColorMode) => void) {
const handleChange = (event: Event) => {
listener((event as CustomEvent<ConsoleColorMode>).detail);
};
window.addEventListener(COLOR_MODE_EVENT, handleChange);
return () => window.removeEventListener(COLOR_MODE_EVENT, handleChange);
}