diff --git a/services/src/main/java/org/keycloak/services/resources/account/AccountCredentialResource.java b/services/src/main/java/org/keycloak/services/resources/account/AccountCredentialResource.java index ba980509ca..6acc5ccc15 100644 --- a/services/src/main/java/org/keycloak/services/resources/account/AccountCredentialResource.java +++ b/services/src/main/java/org/keycloak/services/resources/account/AccountCredentialResource.java @@ -57,6 +57,145 @@ public class AccountCredentialResource { // models.forEach(c -> c.setSecretData(null)); // return models.stream().map(ModelToRepresentation::toRepresentation).collect(Collectors.toList()); // } + + private static class CredentialContainer { + // ** These first three attributes can be ordinary UI text or a key into + // a localized message bundle. Typically, it will be a key, but + // the UI will work just fine if you don't care about localization + // and you want to just send UI text. + // + // Also, the ${} shown in Apicurio is not needed. + private String category; // ** + private String type; // ** + private String helptext; // ** + private boolean enabled; + private String createAction; + private String updateAction; + private boolean removeable; + private List userCredentials; + + public CredentialContainer(String category, String type, String helptext, boolean enabled, String createAction, String updateAction, boolean removeable,List userCredentials) { + this.category = category; + this.type = type; + this.helptext = helptext; + this.enabled = enabled; + this.createAction = createAction; + this.updateAction = updateAction; + this.removeable = removeable; + this.userCredentials = userCredentials; + } + + public String getCategory() { + return category; + } + + public String getType() { + return type; + } + + public String getHelptext() { + return helptext; + } + + public boolean isEnabled() { + return enabled; + } + + public String getCreateAction() { + return createAction; + } + + public String getUpdateAction() { + return updateAction; + } + + public boolean isRemoveable() { + return removeable; + } + + public List getUserCredentials() { + return userCredentials; + } + + } + + @GET + @NoCache + @Produces(javax.ws.rs.core.MediaType.APPLICATION_JSON) + public List dummyCredentialTypes(){ + auth.requireOneOf(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_PROFILE); + List models = session.userCredentialManager().getStoredCredentials(realm, user); + + List passwordUserCreds = new java.util.ArrayList<>(); + passwordUserCreds.add(models.get(0)); + + List otpUserCreds = new java.util.ArrayList<>(); + if (models.size() > 1) otpUserCreds.add(models.get(1)); + if (models.size() > 2) otpUserCreds.add(models.get(2)); + + List webauthnUserCreds = new java.util.ArrayList<>(); + CredentialModel webauthnCred = new CredentialModel(); + webauthnCred.setId("bogus-id"); + webauthnCred.setUserLabel("yubikey key"); + webauthnCred.setCreatedDate(1579122652382L); + webauthnUserCreds.add(webauthnCred); + + List webauthnStrongUserCreds = new java.util.ArrayList<>(); + CredentialModel webauthnStrongCred = new CredentialModel(); + webauthnStrongCred.setId("bogus-id-for-webauthnStrong"); + webauthnStrongCred.setUserLabel("My very strong key with required PIN"); + webauthnStrongCred.setCreatedDate(1579122652382L); + webauthnUserCreds.add(webauthnStrongCred); + + CredentialContainer password = new CredentialContainer( + "password", + "password", + "passwordHelptext", + true, + null, // no create action + "UPDATE_PASSWORD", + false, + passwordUserCreds + ); + CredentialContainer otp = new CredentialContainer( + "two-factor", + "otp", + "otpHelptext", + true, + "CONFIGURE_TOTP", + null, // no update action + true, + otpUserCreds + ); + CredentialContainer webAuthn = new CredentialContainer( + "two-factor", + "webauthn", + "webauthnHelptext", + true, + "CONFIGURE_WEBAUTHN", + null, // no update action + true, + webauthnUserCreds + ); + CredentialContainer passwordless = new CredentialContainer( + "passwordless", + "webauthn-passwordless", + "webauthn-passwordlessHelptext", + true, + "CONFIGURE_WEBAUTHN_STRONG", + null, // no update action + true, + webauthnStrongUserCreds + ); + + List dummyCreds = new java.util.ArrayList<>(); + dummyCreds.add(password); + dummyCreds.add(otp); + dummyCreds.add(webAuthn); + dummyCreds.add(passwordless); + + return dummyCreds; + } // // // @GET @@ -72,17 +211,17 @@ public class AccountCredentialResource { // .collect(Collectors.toList()); // } // -// /** -// * Remove a credential for a user -// * -// */ -// @Path("{credentialId}") -// @DELETE -// @NoCache -// public void removeCredential(final @PathParam("credentialId") String credentialId) { -// auth.require(AccountRoles.MANAGE_ACCOUNT); -// session.userCredentialManager().removeStoredCredential(realm, user, credentialId); -// } + /** + * Remove a credential for a user + * + */ + @Path("{credentialId}") + @DELETE + @NoCache + public void removeCredential(final @PathParam("credentialId") String credentialId) { + auth.require(AccountRoles.MANAGE_ACCOUNT); + session.userCredentialManager().removeStoredCredential(realm, user, credentialId); + } // // /** // * Update a credential label for a user diff --git a/themes/src/main/resources/theme/keycloak-preview/account/messages/messages_en.properties b/themes/src/main/resources/theme/keycloak-preview/account/messages/messages_en.properties index 5c4db85122..7ba8784c44 100644 --- a/themes/src/main/resources/theme/keycloak-preview/account/messages/messages_en.properties +++ b/themes/src/main/resources/theme/keycloak-preview/account/messages/messages_en.properties @@ -59,16 +59,21 @@ unLink=Unlink Account # Signing In Page signingIn=Signing In signingInSubMessage=Configure ways to sign in. -twoFactorEnabled=Two-factor authentication is enabled. -twoFactorDisabled=Two-factor authentication is disabled. -twoFactorAuth=Two-Factor Authentication -mobileAuthDefault=Mobile Authenticator (Default) -removeMobileAuth=Remove Mobile Authenticator -stopMobileAuth=Stop using Mobile Authenticator? -setUp=Set Up +credentialCreatedAt=Created +successRemovedMessage={0} was removed. +stopUsingCred=Stop using {0}? +removeCred=Remove {0} +setUpNew=Set up {0} +notSetUp={0} is not set up. +two-factor=Two-Factor Authentication passwordless=Passwordless -lastUpdate=Last Update unknown=Unknown +otp=One Time Password +webauthn=WebAuthn +webauthn-passwordless=WebAuthn Passwordless +otpHelptext=A one-time password (OTP), is a password that is valid for only one login session. +webauthnHelptext=WebAuthn lets web applications authenticate users without storing their passwords on servers. +webauthn-passwordlessHelptext=I need help with this help text. Any suggestions? # Applications page applicationsPageTitle=Applications diff --git a/themes/src/main/resources/theme/keycloak-preview/account/resources/app/account-service/account.service.ts b/themes/src/main/resources/theme/keycloak-preview/account/resources/app/account-service/account.service.ts index 4a36375a34..e713d01354 100644 --- a/themes/src/main/resources/theme/keycloak-preview/account/resources/app/account-service/account.service.ts +++ b/themes/src/main/resources/theme/keycloak-preview/account/resources/app/account-service/account.service.ts @@ -104,7 +104,7 @@ export class AccountServiceClient { } private makeConfig(endpoint: string, config: AxiosRequestConfig = {}): Promise { - return new Promise( (resolve: ConfigResolve, reject: ErrorReject) => { + return new Promise( (resolve: ConfigResolve) => { this.kcSvc.getToken() .then( (token: string) => { resolve( { diff --git a/themes/src/main/resources/theme/keycloak-preview/account/resources/app/content/ContentAlert.tsx b/themes/src/main/resources/theme/keycloak-preview/account/resources/app/content/ContentAlert.tsx index 959f7dbbf2..3fbdec2a5c 100644 --- a/themes/src/main/resources/theme/keycloak-preview/account/resources/app/content/ContentAlert.tsx +++ b/themes/src/main/resources/theme/keycloak-preview/account/resources/app/content/ContentAlert.tsx @@ -39,38 +39,38 @@ export class ContentAlert extends React.Component { this.setState({isVisible: false}); } - private postAlert = (message: string, variant: AlertVariant) => { + private postAlert = (variant: AlertVariant, message: string, params?: string[]) => { this.setState({isVisible: true, - message: Msg.localize(message), + message: Msg.localize(message, params), variant}); if (variant !== 'danger') { diff --git a/themes/src/main/resources/theme/keycloak-preview/account/resources/app/content/applications-page/ApplicationsPage.tsx b/themes/src/main/resources/theme/keycloak-preview/account/resources/app/content/applications-page/ApplicationsPage.tsx index 9c0d118b70..36b562351f 100644 --- a/themes/src/main/resources/theme/keycloak-preview/account/resources/app/content/applications-page/ApplicationsPage.tsx +++ b/themes/src/main/resources/theme/keycloak-preview/account/resources/app/content/applications-page/ApplicationsPage.tsx @@ -29,7 +29,7 @@ import { GridItem, } from '@patternfly/react-core'; -import { InfoAltIcon, CheckIcon, LinkIcon, BuilderImageIcon } from '@patternfly/react-icons'; +import { InfoAltIcon, CheckIcon, BuilderImageIcon } from '@patternfly/react-icons'; import { ContentPage } from '../ContentPage'; import { ContinueCancelModal } from '../../widgets/ContinueCancelModal'; import { AccountServiceClient } from '../../account-service/account.service'; @@ -110,6 +110,8 @@ export class ApplicationsPage extends React.Component {this.state.applications.map((application: Application, appIndex: number) => { + const appUrl: string = application.userConsentRequired ? application.baseUrl : '/auth' + application.baseUrl; + return ( @@ -132,7 +134,10 @@ export class ApplicationsPage extends React.Component, - {application.baseUrl} + , ]} /> diff --git a/themes/src/main/resources/theme/keycloak-preview/account/resources/app/content/device-activity-page/DeviceActivityPage.tsx b/themes/src/main/resources/theme/keycloak-preview/account/resources/app/content/device-activity-page/DeviceActivityPage.tsx index 0064d1c0ba..6b813837ca 100644 --- a/themes/src/main/resources/theme/keycloak-preview/account/resources/app/content/device-activity-page/DeviceActivityPage.tsx +++ b/themes/src/main/resources/theme/keycloak-preview/account/resources/app/content/device-activity-page/DeviceActivityPage.tsx @@ -158,7 +158,7 @@ export class DeviceActivityPage extends React.Component; +type CredContainerMap = Map; + +interface UserCredential { + id: string; + type: string; + userLabel: string; + createdDate: number; + strCreatedDate?: string; +} + +// A CredentialContainer is unique by combo of credential type and credential category +interface CredentialContainer { + category: CredCategory; + type: CredType; + helptext?: string; + createAction: string; + updateAction: string; + removeable: boolean; + userCredentials: UserCredential[]; +} + interface SigningInPageProps extends RouteComponentProps { } interface SigningInPageState { - twoFactorEnabled: boolean; - twoFactorEnabledText: string; - isTotpConfigured: boolean; - lastPasswordUpdate?: number; + // Credential containers organized by category then type + credentialContainers: CredContainerMap; } /** @@ -64,120 +86,209 @@ interface SigningInPageState { class SigningInPage extends React.Component { private readonly updatePassword: AIACommand = new AIACommand('UPDATE_PASSWORD', this.props.location.pathname); private readonly setUpTOTP: AIACommand = new AIACommand('CONFIGURE_TOTP', this.props.location.pathname); - + public constructor(props: SigningInPageProps) { super(props); this.state = { - twoFactorEnabled: true, - twoFactorEnabledText: Msg.localize('twoFactorEnabled'), - isTotpConfigured: features.isTotpConfigured, + credentialContainers: new Map() } - this.setLastPwdUpdate(); + + this.getCredentialContainers(); } - private setLastPwdUpdate(): void { - AccountServiceClient.Instance.doGet("/credentials/password") - .then((response: AxiosResponse) => { - if (response.data.lastUpdate) { - const lastUpdate: number = response.data.lastUpdate; - this.setState({lastPasswordUpdate: lastUpdate}); - } + private getCredentialContainers(): void { + AccountServiceClient.Instance.doGet("/credentials") + .then((response: AxiosResponse) => { + + const allContainers: CredContainerMap = new Map(); + response.data.forEach(container => { + let categoryMap = allContainers.get(container.category); + if (!categoryMap) { + categoryMap = new Map(); + allContainers.set(container.category, categoryMap); + } + categoryMap.set(container.type, container); + }); + + this.setState({credentialContainers: allContainers}); + console.log({allContainers}) }); } - - private handleTwoFactorSwitch = () => { - if (this.state.twoFactorEnabled) { - this.setState({twoFactorEnabled: false, twoFactorEnabledText: Msg.localize('twoFactorDisabled')}) - } else { - this.setState({twoFactorEnabled: true, twoFactorEnabledText: Msg.localize('twoFactorEnabled')}) - } - } - private handleRemoveTOTP = () => { - AccountServiceClient.Instance.doDelete("/totp/remove") + private handleRemove = (credentialId: string, userLabel: string) => { + AccountServiceClient.Instance.doDelete("/credentials/" + credentialId) .then(() => { - this.setState({isTotpConfigured: false}); - ContentAlert.success('successTotpRemovedMessage'); + this.getCredentialContainers(); + ContentAlert.success('successRemovedMessage', [userLabel]); }); } public render(): React.ReactNode { - let lastPwdUpdate: string = Msg.localize('unknown'); - if (this.state.lastPasswordUpdate) { - lastPwdUpdate = moment(this.state.lastPasswordUpdate).format('LLL'); - } - return ( - - - - <strong><Msg msgKey='password'/></strong> - - - - , - : {lastPwdUpdate}, - - ]}/> - - - - - - - - - <strong><Msg msgKey='twoFactorAuth'/></strong> - - - - - - - - - - ]}/> - {!this.state.isTotpConfigured && - - - - } - {this.state.isTotpConfigured && - - - - } - - - - - - <strong><Msg msgKey='passwordless'/></strong> - - + {this.renderCategories()} ); } + private renderCategories(): React.ReactNode { + return (<> { + Array.from(this.state.credentialContainers.keys()).map(category => ( + + + <strong><Msg msgKey={category}/></strong> + + + {this.renderTypes(this.state.credentialContainers.get(category)!)} + + + )) + + }) + } + + private renderTypes(credTypeMap: CredTypeMap): React.ReactNode { + return (<> { + Array.from(credTypeMap.keys()).map((credType: CredType, index: number, typeArray: string[]) => ([ + this.renderCredTypeTitle(credTypeMap.get(credType)!), + this.renderUserCredentials(credTypeMap, credType), + this.renderEmptyRow(credTypeMap.get(credType)!.type, index === typeArray.length - 1) + ])) + }) + } + + private renderEmptyRow(type: string, isLast: boolean): React.ReactNode { + if (isLast) return; // don't put empty row at the end + + return ( + + + ]}/> + + + ) + } + + private renderUserCredentials(credTypeMap: CredTypeMap, credType: CredType): React.ReactNode { + const userCredentials: UserCredential[] = credTypeMap.get(credType)!.userCredentials; + const removeable: boolean = credTypeMap.get(credType)!.removeable; + const updateAction: string = credTypeMap.get(credType)!.updateAction; + const type: string = credTypeMap.get(credType)!.type; + + if (userCredentials.length === 0) { + const localizedType = Msg.localize(type); + return ( + + + , + , + + ]}/> + + + ); + } + + userCredentials.forEach(credential => { + if (!credential.userLabel) credential.userLabel = Msg.localize(credential.type); + credential.strCreatedDate = moment(credential.createdDate).format('LLL'); + }); + + let updateAIA: AIACommand; + if (updateAction) updateAIA = new AIACommand(updateAction, this.props.location.pathname); + + return ( + { + userCredentials.map(credential => ( + + + {credential.userLabel}, + : {credential.strCreatedDate}, + + ]}/> + + + + + )) + } + ) + } + + private renderCredTypeTitle(credContainer: CredentialContainer): React.ReactNode { + if (!credContainer.createAction) return; + + const setupAction: AIACommand = new AIACommand(credContainer.createAction, this.props.location.pathname); + const credContainerType: string = Msg.localize(credContainer.type); + + return ( + + + + + <strong><Msg msgKey={credContainer.type}/></strong> + + + , + + ]}/> + + + + + + ) + } + }; +type CredRemover = (credentialId: string, userLabel: string) => void; +interface CredentialActionProps {credential: UserCredential; + removeable: boolean; + updateAction: AIACommand; + credRemover: CredRemover;}; +class CredentialAction extends React.Component { + + render(): React.ReactNode { + if (this.props.updateAction) { + return ( + + + + ) + } + + if (this.props.removeable) { + const userLabel: string = this.props.credential.userLabel; + return ( + + this.props.credRemover(this.props.credential.id, userLabel)} + /> + + ) + } + } +} + const SigningInPageWithRouter = withRouter(SigningInPage); export { SigningInPageWithRouter as SigningInPage}; \ No newline at end of file diff --git a/themes/src/main/resources/theme/keycloak-preview/account/resources/app/widgets/Msg.tsx b/themes/src/main/resources/theme/keycloak-preview/account/resources/app/widgets/Msg.tsx index 64c2b28af5..25738bde91 100644 --- a/themes/src/main/resources/theme/keycloak-preview/account/resources/app/widgets/Msg.tsx +++ b/themes/src/main/resources/theme/keycloak-preview/account/resources/app/widgets/Msg.tsx @@ -19,7 +19,7 @@ import * as React from 'react'; declare const l18nMsg: {[key: string]: string}; export interface MsgProps { - readonly msgKey: string; + readonly msgKey: string | undefined; readonly params?: string[]; } @@ -35,7 +35,9 @@ export class Msg extends React.Component { ); } - public static localize(msgKey: string, params?: string[]): string { + public static localize(msgKey: string | undefined, params?: string[]): string { + if (msgKey === undefined) return ''; + let message: string = l18nMsg[this.processKey(msgKey)]; if (message === undefined) message = msgKey; diff --git a/themes/src/main/resources/theme/keycloak-preview/account/resources/systemjs.config.js b/themes/src/main/resources/theme/keycloak-preview/account/resources/systemjs.config.js index a7e75bef1a..d7c3734e73 100644 --- a/themes/src/main/resources/theme/keycloak-preview/account/resources/systemjs.config.js +++ b/themes/src/main/resources/theme/keycloak-preview/account/resources/systemjs.config.js @@ -1333,7 +1333,7 @@ './icons/playstation-icon.js': '@empty', './icons/plug-icon.js': '@empty', './icons/plugged-icon.js': '@empty', - './icons/plus-circle-icon.js': '@empty', + //'./icons/plus-circle-icon.js': '@empty', './icons/plus-icon.js': '@empty', './icons/plus-square-icon.js': '@empty', './icons/podcast-icon.js': '@empty',