新增真实账户数据驱动的身份档案卡

个人资料改为“身份卡 + 编辑工作区”双栏布局
侧栏加入语义图标、产品域标题和更明确的选中状态
页面首屏增加身份控制台层级标识
用户名、邮箱、姓名、语言会随表单内容实时呈现
390px 移动端自动堆叠,无横向溢出
相关组件已由 Keycloakify 正式认领,后续同步不会覆盖
This commit is contained in:
biss committed 2026-08-04 15:42:46 +08:00
1 parent 78743fb436
commit c1b36b4d72
6 files changed
+1763 -262

No files matched your search

+3 -3
View File
@@ -7,6 +7,8 @@
# admin/index.css
# admin/KcAdminUi.tsx
# account/assets/logo.svg
# account/personal-info/PersonalInfo.tsx
# account/root/PageNav.tsx
# admin/assets/logo.svg
# admin/i18n/messages_en.properties
# admin/i18n/messages_zh_Hans.properties
@@ -15,6 +17,7 @@
# email/messages/messages_en.properties
# email/messages/messages_zh_CN.properties
# email/messages/messages_zh_TW.properties
# account/components/page/Page.tsx
# === Owned files end =====
# === @keycloakify/email-native v260007.0.0 ===
@@ -904,7 +907,6 @@
/account/i18n/messages_zh_Hans.properties
/account/i18n/messages_zh_Hant.properties
/account/organizations/Organizations.tsx
/account/personal-info/PersonalInfo.tsx
/account/resources/EditTheResource.tsx
/account/resources/PermissionRequest.tsx
/account/resources/Resources.tsx
@@ -914,7 +916,6 @@
/account/resources/ShareTheResource.tsx
/account/root/header.module.css
/account/root/Header.tsx
/account/root/PageNav.tsx
/account/root/Root.tsx
/account/utils/formatDate.ts
/account/utils/joinPath.ts
@@ -1074,4 +1075,3 @@
/account/assets/passkeys/zoho-light.svg
/account/assets/passkeys/ztpass-light.png
/account/components/datalist/EmptyRow.tsx
/account/components/page/Page.tsx
+51
View File
@@ -0,0 +1,51 @@
/**
* 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/components/page/Page.tsx" --revert
*/
/* eslint-disable */
// @ts-nocheck
import {
PageSection,
Text,
TextContent,
Title
} from "../../../shared/@patternfly/react-core";
import { PropsWithChildren } from "react";
type PageProps = {
title: string;
description: string;
};
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
</span>
<TextContent>
<Title headingLevel="h1" data-testid="page-heading">
{title}
</Title>
<Text component="p">{description}</Text>
</TextContent>
</div>
<div className="nexus-page-intro__mark" aria-hidden="true">
<span>ID</span>
<i />
<span>ACCOUNT</span>
</div>
</div>
</PageSection>
<PageSection variant="light">{children}</PageSection>
</>
);
};
+259
View File
@@ -0,0 +1,259 @@
/**
* 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/personal-info/PersonalInfo.tsx" --revert
*/
/* eslint-disable */
// @ts-nocheck
import {
UserProfileFields,
beerify,
debeerify,
setUserProfileServerError,
useEnvironment
} from "../../shared/keycloak-ui-shared";
import {
ActionGroup,
Alert,
AlertVariant,
Button,
ExpandableSection,
Form,
Spinner
} from "../../shared/@patternfly/react-core";
import { ExternalLinkSquareAltIcon } from "../../shared/@patternfly/react-icons";
import { TFunction } from "i18next";
import { useState } from "react";
import { ErrorOption, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { getPersonalInfo, getSupportedLocales, savePersonalInfo } from "../api/methods";
import { UserProfileMetadata, UserRepresentation } from "../api/representations";
import { Page } from "../components/page/Page";
import { type AccountEnvironment } from "..";
import type { TFuncKey } from "../i18n-type";
import { useAccountAlerts } from "../utils/useAccountAlerts";
import { usePromise } from "../utils/usePromise";
export const PersonalInfo = () => {
const { t } = useTranslation();
const context = useEnvironment<AccountEnvironment>();
const [userProfileMetadata, setUserProfileMetadata] = useState<UserProfileMetadata>();
const [supportedLocales, setSupportedLocales] = useState<string[]>([]);
const form = useForm<UserRepresentation>({ mode: "onChange" });
const { handleSubmit, reset, setValue, setError } = form;
const profile = form.watch();
const { addAlert } = useAccountAlerts();
usePromise(
signal =>
Promise.all([
getPersonalInfo({ signal, context }),
getSupportedLocales({ signal, context })
]),
([personalInfo, supportedLocales]) => {
setUserProfileMetadata(personalInfo.userProfileMetadata);
setSupportedLocales(supportedLocales);
reset(personalInfo);
Object.entries(personalInfo.attributes || {}).forEach(([k, v]) =>
setValue(`attributes[${beerify(k)}]`, v)
);
}
);
const onSubmit = async (user: UserRepresentation) => {
try {
const attributes = Object.fromEntries(
Object.entries(user.attributes || {}).map(([k, v]) => [debeerify(k), v])
);
await savePersonalInfo(context, { ...user, attributes });
const locale = attributes["locale"]?.toString();
if (locale) {
window.dispatchEvent(
new CustomEvent("languageChanged", { detail: { language: locale } })
);
}
await context.keycloak.updateToken();
addAlert(t("accountUpdatedMessage"));
} catch (error) {
addAlert(t("accountUpdatedError"), AlertVariant.danger);
setUserProfileServerError(
{ responseData: { errors: error as any } },
(name: string | number, error: unknown) =>
setError(name as string, error as ErrorOption),
((key: TFuncKey, param?: object) => t(key, param as any)) as TFunction
);
}
};
if (!userProfileMetadata) {
return <Spinner />;
}
const allFieldsReadOnly = () =>
userProfileMetadata.attributes
.map(a => a.readOnly)
.reduce((p, c) => p && c, true);
const {
updateEmailFeatureEnabled,
updateEmailActionEnabled,
isRegistrationEmailAsUsername,
isEditUserNameAllowed
} = context.environment.features;
const displayName =
[profile.firstName, profile.lastName].filter(Boolean).join(" ").trim() ||
profile.username ||
t("personalInfo");
const initials =
[profile.firstName, profile.lastName]
.filter(Boolean)
.map(value => value!.trim().charAt(0))
.join("")
.slice(0, 2)
.toUpperCase() || displayName.slice(0, 2).toUpperCase();
const profileLocale =
profile.attributes?.locale?.toString() || context.environment.locale;
return (
<Page title={t("personalInfo")} description={t("personalInfoDescription")}>
<div className="nexus-profile-layout">
<aside className="nexus-identity-card" aria-label={t("personalInfo")}>
<div className="nexus-identity-card__topline">
<span>Identity record</span>
<i aria-hidden="true" />
</div>
<div className="nexus-identity-card__avatar" aria-hidden="true">
<span>{initials}</span>
</div>
<div className="nexus-identity-card__name">
<span>{t("personalInfo")}</span>
<h2>{displayName}</h2>
<p>{profile.email || profile.username}</p>
</div>
<dl className="nexus-identity-card__facts">
<div>
<dt>{t("username")}</dt>
<dd>{profile.username || "—"}</dd>
</div>
<div>
<dt>{t("email")}</dt>
<dd>{profile.email || "—"}</dd>
</div>
<div>
<dt>Locale</dt>
<dd>{profileLocale}</dd>
</div>
</dl>
<div className="nexus-identity-card__footer">
<span aria-hidden="true" />
NEXUS identity directory
</div>
</aside>
<section className="nexus-profile-editor">
<header className="nexus-profile-editor__header">
<span>Profile data</span>
<div>
<h2>{t("personalInfo")}</h2>
<p>{t("personalInfoDescription")}</p>
</div>
</header>
<Form isHorizontal onSubmit={handleSubmit(onSubmit)}>
<UserProfileFields
form={form}
userProfileMetadata={userProfileMetadata}
supportedLocales={supportedLocales}
currentLocale={context.environment.locale}
t={
((key: unknown, params) =>
t(key as TFuncKey, params as any)) as TFunction
}
renderer={attribute => {
const annotations = attribute.annotations
? attribute.annotations
: {};
return attribute.name === "email" &&
updateEmailFeatureEnabled &&
updateEmailActionEnabled &&
annotations["kc.required.action.supported"] &&
(!isRegistrationEmailAsUsername ||
isEditUserNameAllowed) ? (
<Button
id="update-email-btn"
variant="link"
onClick={() =>
context.keycloak.login({
action: "UPDATE_EMAIL"
})
}
icon={<ExternalLinkSquareAltIcon />}
iconPosition="right"
>
{t("updateEmail")}
</Button>
) : undefined;
}}
/>
{!allFieldsReadOnly() && (
<ActionGroup>
<Button
data-testid="save"
type="submit"
id="save-btn"
variant="primary"
>
{t("save")}
</Button>
<Button
data-testid="cancel"
id="cancel-btn"
variant="link"
onClick={() => reset()}
>
{t("cancel")}
</Button>
</ActionGroup>
)}
{context.environment.features.deleteAccountAllowed && (
<ExpandableSection
data-testid="delete-account"
toggleText={t("deleteAccount")}
>
<Alert
isInline
title={t("deleteAccount")}
variant="danger"
actionLinks={
<Button
id="delete-account-btn"
variant="danger"
onClick={() =>
context.keycloak.login({
action: "delete_account"
})
}
className="delete-button"
>
{t("delete")}
</Button>
}
>
{t("deleteAccountWarning")}
</Alert>
</ExpandableSection>
)}
</Form>
</section>
</div>
</Page>
);
};
export default PersonalInfo;
Loaded 3 of 6 files, more files were not shown because too many files have changed in this diff. Show more