diff --git a/src/account/Fallback.tsx b/src/account/Fallback.tsx index 8ef67146..03e07d49 100644 --- a/src/account/Fallback.tsx +++ b/src/account/Fallback.tsx @@ -8,6 +8,7 @@ const Password = lazy(() => import("keycloakify/account/pages/Password")); const Account = lazy(() => import("keycloakify/account/pages/Account")); const Sessions = lazy(() => import("keycloakify/account/pages/Sessions")); const Totp = lazy(() => import("keycloakify/account/pages/Totp")); +const Applications = lazy(() => import("keycloakify/account/pages/Applications")); export default function Fallback(props: PageProps<KcContext, I18n>) { const { kcContext, ...rest } = props; @@ -24,6 +25,8 @@ export default function Fallback(props: PageProps<KcContext, I18n>) { return <Account kcContext={kcContext} {...rest} />; case "totp.ftl": return <Totp kcContext={kcContext} {...rest} />; + case "applications.ftl": + return <Applications kcContext={kcContext} {...rest} />; } assert<Equals<typeof kcContext, never>>(false); })()} diff --git a/src/account/kcContext/KcContext.ts b/src/account/kcContext/KcContext.ts index 1cce3b2c..ad6cedce 100644 --- a/src/account/kcContext/KcContext.ts +++ b/src/account/kcContext/KcContext.ts @@ -3,7 +3,7 @@ import { assert } from "tsafe/assert"; import type { Equals } from "tsafe"; import { type ThemeType } from "keycloakify/bin/constants"; -export type KcContext = KcContext.Password | KcContext.Account | KcContext.Sessions | KcContext.Totp; +export type KcContext = KcContext.Password | KcContext.Account | KcContext.Sessions | KcContext.Totp | KcContext.Applications; export declare namespace KcContext { export type Common = { @@ -180,6 +180,71 @@ export declare namespace KcContext { }; stateChecker: string; }; + + export type Applications = Common & { + pageId: "applications.ftl"; + features: { + log: boolean; + identityFederation: boolean; + authorization: boolean; + passwordUpdateSupported: boolean; + }; + stateChecker: string; + applications: { + applications: { + realmRolesAvailable: { name: string; description: string }[]; + resourceRolesAvailable: Record< + string, + { + roleName: string; + roleDescription: string; + clientName: string; + clientId: string; + }[] + >; + additionalGrants: string[]; + clientScopesGranted: string[]; + effectiveUrl?: string; + client: { + consentScreenText: string; + surrogateAuthRequired: boolean; + bearerOnly: boolean; + id: string; + protocolMappersStream: Record<string, unknown>; + includeInTokenScope: boolean; + redirectUris: string[]; + fullScopeAllowed: boolean; + registeredNodes: Record<string, unknown>; + enabled: boolean; + clientAuthenticatorType: string; + realmScopeMappingsStream: Record<string, unknown>; + scopeMappingsStream: Record<string, unknown>; + displayOnConsentScreen: boolean; + clientId: string; + rootUrl: string; + authenticationFlowBindingOverrides: Record<string, unknown>; + standardFlowEnabled: boolean; + attributes: Record<string, unknown>; + publicClient: boolean; + alwaysDisplayInConsole: boolean; + consentRequired: boolean; + notBefore: string; + rolesStream: Record<string, unknown>; + protocol: string; + dynamicScope: boolean; + directAccessGrantsEnabled: boolean; + name: string; + serviceAccountsEnabled: boolean; + frontchannelLogout: boolean; + nodeReRegistrationTimeout: string; + implicitFlowEnabled: boolean; + baseUrl: string; + webOrigins: string[]; + realm: Record<string, unknown>; + }; + }[]; + }; + }; } { diff --git a/src/account/kcContext/kcContextMocks.ts b/src/account/kcContext/kcContextMocks.ts index 6f52d8d3..1afd18f3 100644 --- a/src/account/kcContext/kcContextMocks.ts +++ b/src/account/kcContext/kcContextMocks.ts @@ -198,7 +198,7 @@ export const kcContextMocks: KcContext[] = [ } ] }, - "stateChecker": "" + "stateChecker": "g6WB1FaYnKotTkiy7ZrlxvFztSqS0U8jvHsOOOb2z4g" }), id<KcContext.Totp>({ ...kcContextCommonMock, diff --git a/src/account/pages/Applications.tsx b/src/account/pages/Applications.tsx new file mode 100644 index 00000000..c1a51da4 --- /dev/null +++ b/src/account/pages/Applications.tsx @@ -0,0 +1,138 @@ +import { clsx } from "keycloakify/tools/clsx"; +import type { PageProps } from "keycloakify/account/pages/PageProps"; +import { useGetClassName } from "keycloakify/account/lib/useGetClassName"; +import type { KcContext } from "../kcContext"; +import type { I18n } from "../i18n"; + +function isArrayWithEmptyObject(variable: any): boolean { + return Array.isArray(variable) && variable.length === 1 && typeof variable[0] === "object" && Object.keys(variable[0]).length === 0; +} + +export default function Applications(props: PageProps<Extract<KcContext, { pageId: "applications.ftl" }>, I18n>) { + const { kcContext, i18n, doUseDefaultCss, classes, Template } = props; + + const { getClassName } = useGetClassName({ + doUseDefaultCss, + classes + }); + + const { + url, + applications: { applications }, + stateChecker + } = kcContext; + + const { msg, advancedMsg } = i18n; + + return ( + <Template {...{ kcContext, i18n, doUseDefaultCss, classes }} active="applications"> + <div className="row"> + <div className="col-md-10"> + <h2>{msg("applicationsHtmlTitle")}</h2> + </div> + + <form action={url.applicationsUrl} method="post"> + <input type="hidden" id="stateChecker" name="stateChecker" value={stateChecker} /> + <input type="hidden" id="referrer" name="referrer" value={stateChecker} /> + + <table className="table table-striped table-bordered"> + <thead> + <tr> + <td>{msg("application")}</td> + <td>{msg("availableRoles")}</td> + <td>{msg("grantedPermissions")}</td> + <td>{msg("additionalGrants")}</td> + <td>{msg("action")}</td> + </tr> + </thead> + + <tbody> + {applications.map(application => ( + <tr key={application.client.clientId}> + <td> + {application.effectiveUrl && ( + <a href={application.effectiveUrl}> + {(application.client.name && advancedMsg(application.client.name)) || application.client.clientId} + </a> + )} + {!application.effectiveUrl && + ((application.client.name && advancedMsg(application.client.name)) || application.client.clientId)} + </td> + + <td> + {!isArrayWithEmptyObject(application.realmRolesAvailable) && + application.realmRolesAvailable.map(role => ( + <span key={role.name}> + {role.description ? advancedMsg(role.description) : advancedMsg(role.name)} + {role !== application.realmRolesAvailable[application.realmRolesAvailable.length - 1] && ", "} + </span> + ))} + {!isArrayWithEmptyObject(application.realmRolesAvailable) && application.resourceRolesAvailable && ", "} + {application.resourceRolesAvailable && + Object.keys(application.resourceRolesAvailable).map(resource => ( + <span key={resource}> + {!isArrayWithEmptyObject(application.realmRolesAvailable) && ", "} + {application.resourceRolesAvailable[resource].map(clientRole => ( + <span key={clientRole.roleName}> + {clientRole.roleDescription + ? advancedMsg(clientRole.roleDescription) + : advancedMsg(clientRole.roleName)} + {msg("inResource")}{" "} + <strong> + {clientRole.clientName ? advancedMsg(clientRole.clientName) : clientRole.clientId} + </strong> + {clientRole !== + application.resourceRolesAvailable[resource][ + application.resourceRolesAvailable[resource].length - 1 + ] && ", "} + </span> + ))} + </span> + ))} + </td> + + <td> + {application.client.consentRequired ? ( + application.clientScopesGranted.map(claim => ( + <span key={claim}> + {advancedMsg(claim)} + {claim !== application.clientScopesGranted[application.clientScopesGranted.length - 1] && ", "} + </span> + )) + ) : ( + <strong>{msg("fullAccess")}</strong> + )} + </td> + + <td> + {application.additionalGrants.map(grant => ( + <span key={grant}> + {advancedMsg(grant)} + {grant !== application.additionalGrants[application.additionalGrants.length - 1] && ", "} + </span> + ))} + </td> + + <td> + {(application.client.consentRequired && application.clientScopesGranted.length > 0) || + application.additionalGrants.length > 0 ? ( + <button + type="submit" + className={clsx(getClassName("kcButtonPrimaryClass"), getClassName("kcButtonClass"))} + id={`revoke-${application.client.clientId}`} + name="clientId" + value={application.client.id} + > + {msg("revoke")} + </button> + ) : null} + </td> + </tr> + ))} + </tbody> + </table> + </form> + </div> + </Template> + ); +} diff --git a/src/bin/keycloakify/generateFtl/pageId.ts b/src/bin/keycloakify/generateFtl/pageId.ts index 3688198c..3188198f 100644 --- a/src/bin/keycloakify/generateFtl/pageId.ts +++ b/src/bin/keycloakify/generateFtl/pageId.ts @@ -27,7 +27,7 @@ export const loginThemePageIds = [ "saml-post-form.ftl" ] as const; -export const accountThemePageIds = ["password.ftl", "account.ftl", "sessions.ftl", "totp.ftl"] as const; +export const accountThemePageIds = ["password.ftl", "account.ftl", "sessions.ftl", "totp.ftl", "applications.ftl"] as const; export type LoginThemePageId = (typeof loginThemePageIds)[number]; export type AccountThemePageId = (typeof accountThemePageIds)[number];