diff --git a/.dockerignore b/.dockerignore index 313cb2a..7b3e578 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,3 +6,7 @@ Dockerfile .git/ .gitignore + +# Environment +.env +.envrc diff --git a/.gitignore b/.gitignore index 296e1bc..890a293 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,8 @@ target/ # Original theme -themes/base \ No newline at end of file +themes/base + +# Environment +.env +.envrc diff --git a/Dockerfile b/Dockerfile index bb65355..36a6726 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM maven:3.8.4-jdk-11 as build +FROM maven:3.8.6-jdk-11 as build COPY . /build WORKDIR /build @@ -7,11 +7,11 @@ RUN unset MAVEN_CONFIG && \ ./mvnw versions:set -DnewVersion=LATEST && \ ./mvnw install && \ ./mvnw clean compile package && \ - wget -O keycloak-rest-provider.jar https://github.com/toddkazakov/keycloak-user-migration/releases/download/v2.0/keycloak-rest-provider.jar && \ + wget -O keycloak-rest-provider.jar https://github.com/daniel-frak/keycloak-user-migration/releases/download/1.0.0/keycloak-rest-provider-1.0.0.jar && \ wget -O keycloak-metrics-spi.jar https://github.com/aerogear/keycloak-metrics-spi/releases/download/3.0.0/keycloak-metrics-spi-3.0.0.jar && \ - wget -O keycloak-home-idp-discovery.jar https://github.com/toddkazakov/keycloak-home-idp-discovery/releases/download/v21.3.2/keycloak-home-idp-discovery.jar + wget -O keycloak-home-idp-discovery.jar https://github.com/tidepool-org/keycloak-home-idp-discovery/releases/download/v21.4.0/keycloak-home-idp-discovery.jar -FROM alpine:3.15 as release +FROM alpine:latest as release COPY --from=build /build/admin/target/*.jar /release/extensions/ COPY --from=build /build/*.jar /release/extensions/ diff --git a/Makefile b/Makefile index 2edb51b..ae880cf 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,4 @@ build-artifacts: # Builds the docker image build: build-artifacts - docker build -t tidepool/keycloak-extensions:$(image_tag) . - - + docker build --platform linux/amd64 --tag tidepool/keycloak-extensions:$(image_tag) . diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/ConditionUserInContextFactory.java b/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/ConditionUserInContextFactory.java index c684eb8..2cace99 100755 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/ConditionUserInContextFactory.java +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/ConditionUserInContextFactory.java @@ -22,8 +22,6 @@ public final class ConditionUserInContextFactory implements AuthenticatorFactory public static final String CONF_NEGATE = "negate"; - private Config.Scope config; - @Override public String getDisplayType() { return "Condition - User in Context"; @@ -72,7 +70,6 @@ public Authenticator create(KeycloakSession session) { @Override public void init(Config.Scope config) { - this.config = config; } @Override diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/RedirectToRegistrationPage.java b/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/RedirectToRegistrationPage.java index 8d2aa37..5be3295 100755 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/RedirectToRegistrationPage.java +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/RedirectToRegistrationPage.java @@ -1,6 +1,5 @@ package org.tidepool.keycloak.extensions.authenticator; -import org.jboss.logging.Logger; import org.keycloak.authentication.AuthenticationFlowContext; import org.keycloak.authentication.Authenticator; import org.keycloak.models.*; @@ -9,30 +8,25 @@ import javax.ws.rs.core.Response; import javax.ws.rs.core.UriBuilder; -import javax.ws.rs.core.UriInfo; import java.net.URI; final class RedirectToRegistrationPage implements Authenticator { - private static final Logger LOG = Logger.getLogger(RedirectToRegistrationPage.class); - RedirectToRegistrationPage() { } @Override public void authenticate(AuthenticationFlowContext context) { - URI baseURI = prepareBaseUriBuilder(context).build(); + URI baseURI = prepareBaseUriBuilder(context).build(); URI register = Urls.realmRegisterPage(baseURI, context.getRealm().getName()); context.forceChallenge(Response.seeOther(register).build()); } private UriBuilder prepareBaseUriBuilder(AuthenticationFlowContext context) { - UriInfo uriInfo = context.getUriInfo(); + UriBuilder uriBuilder = context.getUriInfo().getBaseUriBuilder(); ClientModel client = context.getSession().getContext().getClient(); AuthenticationSessionModel authSession = context.getAuthenticationSession(); - String requestURI = uriInfo.getBaseUri().getPath(); - UriBuilder uriBuilder = UriBuilder.fromUri(requestURI); uriBuilder.replaceQuery(null); if (client != null) { diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/RedirectToRegistrationPageFactory.java b/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/RedirectToRegistrationPageFactory.java index fcfc66b..8564eac 100755 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/RedirectToRegistrationPageFactory.java +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/RedirectToRegistrationPageFactory.java @@ -21,8 +21,6 @@ public final class RedirectToRegistrationPageFactory implements AuthenticatorFac private static final String PROVIDER_ID = "redirect-to-registration-page"; - private Config.Scope config; - @Override public String getDisplayType() { return "Redirect to Registration Page"; @@ -65,7 +63,6 @@ public Authenticator create(KeycloakSession session) { @Override public void init(Config.Scope config) { - this.config = config; } @Override diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/RegistrationRoleDiscoveryAuthenticator.java b/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/RegistrationRoleDiscoveryAuthenticator.java new file mode 100644 index 0000000..73dabbf --- /dev/null +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/RegistrationRoleDiscoveryAuthenticator.java @@ -0,0 +1,58 @@ +package org.tidepool.keycloak.extensions.authenticator; + +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.AuthenticationFlowError; +import org.keycloak.authentication.Authenticator; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.protocol.AuthorizationEndpointBase; +import org.tidepool.keycloak.extensions.model.RoleBean; +import org.tidepool.keycloak.extensions.roles.UserRolePromptRequiredAction; + +final class RegistrationRoleDiscoveryAuthenticator implements Authenticator { + + @Override + public void close() { + } + + @Override + public void authenticate(AuthenticationFlowContext context) { + + // Reset APP_INITIATED_FLOW so restart redirects to login, not registration + context.getAuthenticationSession().setClientNote(AuthorizationEndpointBase.APP_INITIATED_FLOW, null); + + String role = context.getUriInfo().getQueryParameters().getFirst(RoleBean.PARAMETER_ROLE); + if (RoleBean.ROLES_SET.contains(role)) { + context.getAuthenticationSession().setAuthNote(RoleBean.AUTH_NOTE_ROLE, role); + context.success(); + } else { + context.challenge(context.form().createForm(UserRolePromptRequiredAction.ROLES_FORM_FTL)); + } + } + + @Override + public void action(AuthenticationFlowContext context) { + String role = context.getHttpRequest().getDecodedFormParameters().getFirst(RoleBean.PARAMETER_ROLE); + if (RoleBean.ROLES_SET.contains(role)) { + context.getAuthenticationSession().setAuthNote(RoleBean.AUTH_NOTE_ROLE, role); + context.success(); + } else { + context.failure(AuthenticationFlowError.INTERNAL_ERROR); + } + } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return true; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + } +} diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/RegistrationRoleDiscoveryAuthenticatorFactory.java b/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/RegistrationRoleDiscoveryAuthenticatorFactory.java new file mode 100644 index 0000000..422f397 --- /dev/null +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/RegistrationRoleDiscoveryAuthenticatorFactory.java @@ -0,0 +1,87 @@ +package org.tidepool.keycloak.extensions.authenticator; + +import java.util.List; +import java.util.Map; + +import org.keycloak.Config.Scope; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.models.AuthenticationExecutionModel.Requirement; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ServerInfoAwareProviderFactory; + +public final class RegistrationRoleDiscoveryAuthenticatorFactory + implements AuthenticatorFactory, ServerInfoAwareProviderFactory { + + private static final String ID = "tidepool-registration-role-discovery"; + + private static final Requirement[] REQUIREMENT_CHOICES = { Requirement.REQUIRED, Requirement.DISABLED }; + + @Override + public Authenticator create(KeycloakSession session) { + return new RegistrationRoleDiscoveryAuthenticator(); + } + + @Override + public void init(Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return ID; + } + + @Override + public String getDisplayType() { + return "Tidepool Registration Role Discovery"; + } + + @Override + public String getReferenceCategory() { + return "registration-role-discovery"; + } + + @Override + public boolean isConfigurable() { + return false; + } + + @Override + public Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + + @Override + public String getHelpText() { + return "Ensures a role is provided upon registration via query parameter or displayed form and sets the associated auth note"; + } + + @Override + public List getConfigProperties() { + return null; + } + + @Override + public Map getOperationalInfo() { + String version = getClass().getPackage().getImplementationVersion(); + if (version == null) { + version = "dev-snapshot"; + } + return Map.of("Version", version); + } +} diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/RegistrationRoleFormAction.java b/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/RegistrationRoleFormAction.java new file mode 100644 index 0000000..36bf2e5 --- /dev/null +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/RegistrationRoleFormAction.java @@ -0,0 +1,59 @@ +package org.tidepool.keycloak.extensions.authenticator; + +import org.keycloak.authentication.FormAction; +import org.keycloak.authentication.FormContext; +import org.keycloak.authentication.ValidationContext; +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserModel; +import org.tidepool.keycloak.extensions.model.RoleBean; + +final class RegistrationRoleFormAction implements FormAction { + + private static final String EVENT_DETAIL_ROLE = "role"; + + @Override + public void close() { + } + + @Override + public void buildPage(FormContext context, LoginFormsProvider form) { + } + + @Override + public void validate(ValidationContext context) { + String role = context.getAuthenticationSession().getAuthNote(RoleBean.AUTH_NOTE_ROLE); + if (RoleBean.ROLES_SET.contains(role)) { + context.getEvent().detail(EVENT_DETAIL_ROLE, role); + } + + context.success(); + } + + @Override + public void success(FormContext context) { + String role = context.getAuthenticationSession().getAuthNote(RoleBean.AUTH_NOTE_ROLE); + if (RoleBean.ROLES_SET.contains(role)) { + RoleModel roleModel = context.getRealm().getRole(role); + if (roleModel != null) { + context.getUser().grantRole(roleModel); + } + } + } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return true; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + } +} diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/RegistrationRoleFormActionFactory.java b/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/RegistrationRoleFormActionFactory.java new file mode 100644 index 0000000..20582b6 --- /dev/null +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/RegistrationRoleFormActionFactory.java @@ -0,0 +1,86 @@ +package org.tidepool.keycloak.extensions.authenticator; + +import java.util.List; +import java.util.Map; + +import org.keycloak.Config.Scope; +import org.keycloak.authentication.FormAction; +import org.keycloak.authentication.FormActionFactory; +import org.keycloak.models.AuthenticationExecutionModel.Requirement; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ServerInfoAwareProviderFactory; + +public final class RegistrationRoleFormActionFactory implements FormActionFactory, ServerInfoAwareProviderFactory { + + private static final String ID = "tidepool-registration-role"; + + private static final Requirement[] REQUIREMENT_CHOICES = { Requirement.REQUIRED, Requirement.DISABLED }; + + @Override + public FormAction create(KeycloakSession session) { + return new RegistrationRoleFormAction(); + } + + @Override + public void init(Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return ID; + } + + @Override + public String getDisplayType() { + return "Tidepool Registration Role"; + } + + @Override + public String getReferenceCategory() { + return "registration-role"; + } + + @Override + public boolean isConfigurable() { + return false; + } + + @Override + public Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + + @Override + public String getHelpText() { + return "Applies the role based upon the associated auth note set by the tidepool-registration-role-discovery authenticator"; + } + + @Override + public List getConfigProperties() { + return null; + } + + @Override + public Map getOperationalInfo() { + String version = getClass().getPackage().getImplementationVersion(); + if (version == null) { + version = "dev-snapshot"; + } + return Map.of("Version", version); + } +} diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/RegistrationTermsFormAction.java b/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/RegistrationTermsFormAction.java new file mode 100644 index 0000000..a461507 --- /dev/null +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/RegistrationTermsFormAction.java @@ -0,0 +1,93 @@ +package org.tidepool.keycloak.extensions.authenticator; + +import java.util.ArrayList; +import java.util.List; + +import javax.ws.rs.core.MultivaluedMap; + +import org.keycloak.authentication.FormAction; +import org.keycloak.authentication.FormContext; +import org.keycloak.authentication.ValidationContext; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.FormMessage; +import org.tidepool.keycloak.extensions.model.RoleBean; +import org.tidepool.keycloak.extensions.services.messages.Messages; + +final class RegistrationTermsFormAction implements FormAction { + + private static final String FORM_TERMS = "terms"; + + private static final String FORM_TERMS_ON = "on"; + + private static final String USER_ATTRIBUTE_TERMS_AND_CONDITIONS = "terms_and_conditions"; + + @Override + public void close() { + } + + @Override + public void buildPage(FormContext context, LoginFormsProvider form) { + } + + @Override + public void validate(ValidationContext context) { + + // TEMPORARY: Currently only for Clinician registration which includes TOS/PP + // agreement on clinician registration form. Remove once TOS/PP agreement + // included on personal registration form. + if (!RoleBean.hasClinicianRoleFromAuthenticationSession(context.getAuthenticationSession())) { + context.success(); + return; + } + + context.getEvent().detail(Details.REGISTER_METHOD, "form"); + + MultivaluedMap formParameters = context.getHttpRequest().getDecodedFormParameters(); + List errors = new ArrayList<>(); + + if (!FORM_TERMS_ON.equalsIgnoreCase(formParameters.getFirst(FORM_TERMS))) { + errors.add(new FormMessage(FORM_TERMS, Messages.TERMS_NOT_ACCEPTED)); + } + + if (errors.size() > 0) { + formParameters.remove(FORM_TERMS); + context.error(Errors.INVALID_REGISTRATION); + context.validationError(formParameters, errors); + } else { + context.success(); + } + } + + @Override + public void success(FormContext context) { + + // TEMPORARY: Currently only for Clinician registration which includes TOS/PP + // agreement on clinician registration form. Remove once TOS/PP agreement + // included on personal registration form. + if (!RoleBean.hasClinicianRoleFromRealmUser(context.getRealm(), context.getUser())) { + return; + } + + Long secondsSinceEpoch = java.time.Instant.now().getEpochSecond(); + context.getUser().setAttribute(USER_ATTRIBUTE_TERMS_AND_CONDITIONS, List.of(String.valueOf(secondsSinceEpoch))); + } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return true; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + } +} diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/RegistrationTermsFormActionFactory.java b/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/RegistrationTermsFormActionFactory.java new file mode 100644 index 0000000..73b3c2a --- /dev/null +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/RegistrationTermsFormActionFactory.java @@ -0,0 +1,86 @@ +package org.tidepool.keycloak.extensions.authenticator; + +import java.util.List; +import java.util.Map; + +import org.keycloak.Config.Scope; +import org.keycloak.authentication.FormAction; +import org.keycloak.authentication.FormActionFactory; +import org.keycloak.models.AuthenticationExecutionModel.Requirement; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ServerInfoAwareProviderFactory; + +public final class RegistrationTermsFormActionFactory implements FormActionFactory, ServerInfoAwareProviderFactory { + + private static final String ID = "tidepool-registration-terms"; + + private static final Requirement[] REQUIREMENT_CHOICES = { Requirement.REQUIRED, Requirement.DISABLED }; + + @Override + public FormAction create(KeycloakSession session) { + return new RegistrationTermsFormAction(); + } + + @Override + public void init(Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return ID; + } + + @Override + public String getDisplayType() { + return "Tidepool Registration Terms"; + } + + @Override + public String getReferenceCategory() { + return "registration-terms"; + } + + @Override + public boolean isConfigurable() { + return false; + } + + @Override + public Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + + @Override + public String getHelpText() { + return "Determines whether the terms have been accepted in the form and sets the terms_and_conditions attribute on the user"; + } + + @Override + public List getConfigProperties() { + return null; + } + + @Override + public Map getOperationalInfo() { + String version = getClass().getPackage().getImplementationVersion(); + if (version == null) { + version = "dev-snapshot"; + } + return Map.of("Version", version); + } +} diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/ResetUserInContextFactory.java b/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/ResetUserInContextFactory.java index 1a6fe71..188fc0f 100755 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/ResetUserInContextFactory.java +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/authenticator/ResetUserInContextFactory.java @@ -21,8 +21,6 @@ public final class ResetUserInContextFactory implements AuthenticatorFactory, Se private static final String PROVIDER_ID = "reset-user-in-context"; - private Config.Scope config; - @Override public String getDisplayType() { return "Reset User in Context"; @@ -65,7 +63,6 @@ public Authenticator create(KeycloakSession session) { @Override public void init(Config.Scope config) { - this.config = config; } @Override diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/login/TidepoolLoginFormsProvider.java b/admin/src/main/java/org/tidepool/keycloak/extensions/login/TidepoolLoginFormsProvider.java new file mode 100644 index 0000000..7387207 --- /dev/null +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/login/TidepoolLoginFormsProvider.java @@ -0,0 +1,42 @@ +package org.tidepool.keycloak.extensions.login; + +import java.net.URI; +import java.util.Locale; + +import javax.ws.rs.core.Response; + +import org.keycloak.forms.login.freemarker.FreeMarkerLoginFormsProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.theme.Theme; +import org.tidepool.keycloak.extensions.model.RoleBean; + +public class TidepoolLoginFormsProvider extends FreeMarkerLoginFormsProvider { + + private static final String FORM_ATTRIBUTE_ROLE = "role"; + + private static final String REGISTER_FORM = "register.ftl"; + private static final String REGISTER_FORM_CLINICIAN = "register-clinician.ftl"; + private static final String REGISTER_FORM_PERSONAL = "register-personal.ftl"; + + public TidepoolLoginFormsProvider(KeycloakSession session) { + super(session); + } + + @Override + protected Response processTemplate(Theme theme, String templateName, Locale locale) { + URI baseUri = super.prepareBaseUriBuilder(false).build(); + + RoleBean roleBean = new RoleBean(realm, baseUri, context, authenticationSession); + attributes.put(FORM_ATTRIBUTE_ROLE, roleBean); + + if (templateName == REGISTER_FORM) { + if (roleBean.hasClinicianRole()) { + templateName = REGISTER_FORM_CLINICIAN; + } else { + templateName = REGISTER_FORM_PERSONAL; + } + } + + return super.processTemplate(theme, templateName, locale); + } +} diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/login/TidepoolLoginFormsProviderFactory.java b/admin/src/main/java/org/tidepool/keycloak/extensions/login/TidepoolLoginFormsProviderFactory.java new file mode 100644 index 0000000..5823ec0 --- /dev/null +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/login/TidepoolLoginFormsProviderFactory.java @@ -0,0 +1,17 @@ +package org.tidepool.keycloak.extensions.login; + +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.forms.login.LoginFormsProviderFactory; +import org.keycloak.forms.login.freemarker.FreeMarkerLoginFormsProviderFactory; +import org.keycloak.models.KeycloakSession; + +import com.google.auto.service.AutoService; + +@AutoService(LoginFormsProviderFactory.class) +public class TidepoolLoginFormsProviderFactory extends FreeMarkerLoginFormsProviderFactory { + + @Override + public LoginFormsProvider create(KeycloakSession session) { + return new TidepoolLoginFormsProvider(session); + } +} diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/model/RoleBean.java b/admin/src/main/java/org/tidepool/keycloak/extensions/model/RoleBean.java new file mode 100644 index 0000000..c1e781a --- /dev/null +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/model/RoleBean.java @@ -0,0 +1,98 @@ +package org.tidepool.keycloak.extensions.model; + +import java.net.URI; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserModel; +import org.keycloak.services.Urls; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.tidepool.keycloak.extensions.resource.RegistrationsRealmResourceProvider; + +public class RoleBean { + + public static final String ROLE_CLINICIAN = "clinician"; + public static final String ROLE_CLINIC_DEPRECATED = "clinic"; + public static final String ROLE_PATIENT = "patient"; + + public static final List ROLES_LIST = Arrays.asList(ROLE_CLINICIAN, ROLE_PATIENT); + public static final Set ROLES_SET = new HashSet<>(ROLES_LIST); + + public static final List ROLES_CLINICIAN_LIST = Arrays.asList(ROLE_CLINICIAN, ROLE_CLINIC_DEPRECATED); + public static final Set ROLES_CLINICIAN_SET = new HashSet<>(ROLES_CLINICIAN_LIST); + + public static final String PARAMETER_ROLE = "role"; + + public static final String AUTH_NOTE_ROLE = "role"; + + private final RealmModel realm; + private final URI baseUri; + private final AuthenticationFlowContext context; + private final AuthenticationSessionModel authenticationSession; + + public RoleBean(RealmModel realm, URI baseUri, AuthenticationFlowContext context, + AuthenticationSessionModel authenticationSession) { + this.realm = realm; + this.baseUri = baseUri; + this.context = context; + this.authenticationSession = authenticationSession; + } + + public boolean hasClinicianRole() { + if (hasClinicianRoleFromAuthenticationSession(authenticationSession)) { + return true; + } + if (context != null && hasClinicianRoleFromRealmUser(context.getRealm(), context.getUser())) { + return true; + } + return false; + } + + public static boolean hasClinicianRoleFromAuthenticationSession(AuthenticationSessionModel authenticationSession) { + if (authenticationSession != null) { + if (ROLES_CLINICIAN_SET.contains(authenticationSession.getAuthNote(AUTH_NOTE_ROLE))) { + return true; + } + } + return false; + } + + public static boolean hasClinicianRoleFromRealmUser(RealmModel realm, UserModel user) { + if (realm != null && user != null) { + for (String clinicianRole : ROLES_CLINICIAN_SET) { + RoleModel clinicianRoleModel = realm.getRole(clinicianRole); + if (user.hasRole(clinicianRoleModel)) { + return true; + } + } + } + return false; + } + + public URI getRegistrationUriForClinicianRole() { + return getRegistrationUriForRole(RoleBean.ROLE_CLINICIAN); + } + + public URI getRegistrationUriForPatientRole() { + return getRegistrationUriForRole(RoleBean.ROLE_PATIENT); + } + + private URI getRegistrationUriForRole(String role) { + return Urls + .realmBase(baseUri) + .path(RegistrationsRealmResourceProvider.class, "registrations") + .path(RegistrationsRealmResourceProvider.class, "restart") + .queryParam(PARAMETER_ROLE, role) + .build(getRealmName()); + + } + + private String getRealmName() { + return realm != null ? realm.getName() : null; + } +} diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/RegistrationsRealmResourceProvider.java b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/RegistrationsRealmResourceProvider.java new file mode 100644 index 0000000..b4cd1f9 --- /dev/null +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/RegistrationsRealmResourceProvider.java @@ -0,0 +1,120 @@ +package org.tidepool.keycloak.extensions.resource; + +import java.net.URI; + +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriBuilder; + +import org.keycloak.authentication.AuthenticationProcessor; +import org.keycloak.common.ClientConnection; +import org.keycloak.events.EventBuilder; +import org.keycloak.events.EventType; +import org.keycloak.http.HttpRequest; +import org.keycloak.models.Constants; +import org.keycloak.models.KeycloakContext; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.protocol.AuthorizationEndpointBase; +import org.keycloak.services.managers.AuthenticationManager; +import org.keycloak.services.managers.AuthenticationSessionManager; +import org.keycloak.services.resource.RealmResourceProvider; +import org.keycloak.services.resources.LoginActionsService; +import org.keycloak.services.resources.SessionCodeChecks; +import org.keycloak.services.util.AuthenticationFlowURLHelper; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.tidepool.keycloak.extensions.model.RoleBean; + +public class RegistrationsRealmResourceProvider implements RealmResourceProvider { + + private static final String PATH_REGISTRATIONS = "{realm}/" + RegistrationsRealmResourceProviderFactory.ID; + private static final String PATH_RESTART = "restart"; + + private KeycloakSession session; + + public RegistrationsRealmResourceProvider(KeycloakSession session) { + this.session = session; + } + + @Override + public void close() { + } + + @Override + public Object getResource() { + return this; + } + + @Path(PATH_REGISTRATIONS) + public RegistrationsRealmResourceProvider registrations( + final @PathParam("realm") String name) { + return this; + } + + /** + * Restart authentication with registration. Allows specification of role + * (clinician or personal) to initiate registration. + * + * Mimics LoginActionsService.restartSession, but restarts with registration + * flow. + * + * @param authSessionId + * @param clientId + * @param tabId + * @param role + * @return + */ + @GET + @Path(PATH_RESTART) + public Response restart( + final @QueryParam(LoginActionsService.AUTH_SESSION_ID) String authSessionId, + final @QueryParam(Constants.CLIENT_ID) String clientId, + final @QueryParam(Constants.TAB_ID) String tabId, + final @QueryParam(RoleBean.PARAMETER_ROLE) String role) { + + KeycloakContext context = session.getContext(); + RealmModel realm = context.getRealm(); + HttpRequest request = context.getHttpRequest(); + ClientConnection clientConnection = context.getConnection(); + EventBuilder event = new EventBuilder(realm, session, clientConnection); + + event.event(EventType.RESTART_AUTHENTICATION); + + SessionCodeChecks checks = new SessionCodeChecks(realm, context.getUri(), request, + clientConnection, session, event, authSessionId, null, null, clientId, tabId, null); + + AuthenticationSessionModel authenticationSession = checks.initialVerifyAuthSession(); + if (authenticationSession == null) { + return checks.getResponse(); + } + + String flowPath = authenticationSession.getClientNote(AuthorizationEndpointBase.APP_INITIATED_FLOW); + if (flowPath == null) { + flowPath = LoginActionsService.REGISTRATION_PATH; + } + + UserSessionModel userSession = new AuthenticationSessionManager(session).getUserSession(authenticationSession); + if (userSession != null) { + AuthenticationManager.backchannelLogout(session, userSession, false); + } + + AuthenticationProcessor.resetFlow(authenticationSession, LoginActionsService.REGISTRATION_PATH); + + URI redirectUri = getLastExecutionUrl(flowPath, null, authenticationSession.getClient().getClientId(), tabId); + + if (role != null) { + redirectUri = UriBuilder.fromUri(redirectUri).queryParam(RoleBean.PARAMETER_ROLE, role).build(); + } + + return Response.status(Response.Status.FOUND).location(redirectUri).build(); + } + + private URI getLastExecutionUrl(String flowPath, String executionId, String clientId, String tabId) { + return new AuthenticationFlowURLHelper(session, session.getContext().getRealm(), session.getContext().getUri()) + .getLastExecutionUrl(flowPath, executionId, clientId, tabId); + } +} diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/RegistrationsRealmResourceProviderFactory.java b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/RegistrationsRealmResourceProviderFactory.java new file mode 100644 index 0000000..dca1d10 --- /dev/null +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/RegistrationsRealmResourceProviderFactory.java @@ -0,0 +1,34 @@ +package org.tidepool.keycloak.extensions.resource; + +import org.keycloak.Config.Scope; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.services.resource.RealmResourceProvider; +import org.keycloak.services.resource.RealmResourceProviderFactory; + +public class RegistrationsRealmResourceProviderFactory implements RealmResourceProviderFactory { + + public static final String ID = "registrations"; + + @Override + public RealmResourceProvider create(KeycloakSession session) { + return new RegistrationsRealmResourceProvider(session); + } + + @Override + public void init(Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return ID; + } +} diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResourceProvider.java b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResourceProvider.java index ef08a44..be1c948 100644 --- a/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResourceProvider.java +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/resource/TidepoolAdminResourceProvider.java @@ -1,6 +1,5 @@ package org.tidepool.keycloak.extensions.resource; -import org.jboss.logging.Logger; import org.jboss.resteasy.spi.ResteasyProviderFactory; import org.keycloak.models.*; import org.keycloak.services.resource.RealmResourceProvider; diff --git a/admin/src/main/java/org/tidepool/keycloak/extensions/services/messages/Messages.java b/admin/src/main/java/org/tidepool/keycloak/extensions/services/messages/Messages.java new file mode 100644 index 0000000..d462769 --- /dev/null +++ b/admin/src/main/java/org/tidepool/keycloak/extensions/services/messages/Messages.java @@ -0,0 +1,7 @@ +package org.tidepool.keycloak.extensions.services.messages; + +public class Messages extends org.keycloak.services.messages.Messages { + + public static final String TERMS_NOT_ACCEPTED = "termsNotAcceptedMessage"; + +} diff --git a/admin/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory b/admin/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory index cdeacd8..4488404 100755 --- a/admin/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory +++ b/admin/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory @@ -1,3 +1,4 @@ org.tidepool.keycloak.extensions.authenticator.ConditionUserInContextFactory org.tidepool.keycloak.extensions.authenticator.RedirectToRegistrationPageFactory -org.tidepool.keycloak.extensions.authenticator.ResetUserInContextFactory \ No newline at end of file +org.tidepool.keycloak.extensions.authenticator.RegistrationRoleDiscoveryAuthenticatorFactory +org.tidepool.keycloak.extensions.authenticator.ResetUserInContextFactory diff --git a/admin/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory b/admin/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory new file mode 100644 index 0000000..b0cde15 --- /dev/null +++ b/admin/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory @@ -0,0 +1,2 @@ +org.tidepool.keycloak.extensions.authenticator.RegistrationRoleFormActionFactory +org.tidepool.keycloak.extensions.authenticator.RegistrationTermsFormActionFactory diff --git a/admin/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory b/admin/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory index 76a824b..f44a238 100644 --- a/admin/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory +++ b/admin/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory @@ -1 +1,2 @@ +org.tidepool.keycloak.extensions.resource.RegistrationsRealmResourceProviderFactory org.tidepool.keycloak.extensions.resource.TidepoolAdminResourceProviderFactory diff --git a/tidepool-theme/login/login-username.ftl b/tidepool-theme/login/login-username.ftl index a9a955a..b729ef7 100644 --- a/tidepool-theme/login/login-username.ftl +++ b/tidepool-theme/login/login-username.ftl @@ -22,9 +22,9 @@ /> <#if messagesPerField.existsError('username')> - +
${kcSanitize(messagesPerField.get('username'))?no_esc} - +
diff --git a/tidepool-theme/login/login.ftl b/tidepool-theme/login/login.ftl index a10973c..afdc1f2 100644 --- a/tidepool-theme/login/login.ftl +++ b/tidepool-theme/login/login.ftl @@ -17,9 +17,9 @@ /> <#if messagesPerField.existsError('username','password')> - +
${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc} - +
@@ -34,9 +34,9 @@ /> <#if usernameHidden?? && messagesPerField.existsError('username','password')> - +
${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc} - +
diff --git a/tidepool-theme/login/messages/messages_en.properties b/tidepool-theme/login/messages/messages_en.properties index bb67f44..9c75b48 100644 --- a/tidepool-theme/login/messages/messages_en.properties +++ b/tidepool-theme/login/messages/messages_en.properties @@ -1,18 +1,25 @@ doRegister=Sign up -doLogIn=Sign in +doLogIn=Log In doForgotPassword=Forgot your password? -registerTitle=Create Tidepool Account +registerTitleClinician=Create Your Clinician Tidepool Account +registerTitlePersonal=Create Your Personal Tidepool Account doCreateAccount=Create Account continue=Continue keepingYourDataSecure=Keeping your data private and secure is important to us! next=Next -letsGetStarted=Sign in to Tidepool +letsGetStarted=Log in to Tidepool enterYourEmail=Enter your email address enterYourUsername=Enter your username enterYourUsernameOrEmail=Enter your username or email address notYou=Not you? noAccount=Don''t have an account? alreadyHaveAnAccount=Already have an account? +needPersonalAccount=Need to manage data for yourself or someone you care for? +needClinicianAccount=Need an account for your work at a clinic? +createAccountPrefix=Create a +createAccountPersonal=Personal Account +createAccountClinician=Clinician Account +createAccountSuffix=instead. saml.post-form.title=Redirecting, please wait... saml.post-form.message=Your browser will automatically redirect you to a login screen. @@ -23,4 +30,17 @@ emailLinkIdpResendVerificationCode=Resend Verification Link emailLinkIdpConfirm=Confirm emailLinkIdpConfirmEmailMessage=Confirm your email address to connect your {0} account with your current {1} account. -emailBoundToIdp=This account is SSO enabled. Please sign in. \ No newline at end of file +emailBoundToIdp=This account is SSO enabled. Please log in. + +termsNotAcceptedMessage=Please accept the Tidepool Applications Terms of Use and Privacy Policy. + +doSignIn=Log In +loginAccountTitle=Log in to your account +loginTitle=Log in to {0} +password-help-text=Log in by entering your password. +auth-username-password-form-help-text=Log in by entering your username and password. +webauthn-doAuthenticate=Log in with Security Key + +home-idp-discovery-help-text=Log in via your home identity provider which will be automatically determined based on your provided email address. + +unknownEmailMessage=This email doesn''t belong to an account yet. diff --git a/tidepool-theme/login/register-clinician.ftl b/tidepool-theme/login/register-clinician.ftl new file mode 100644 index 0000000..96a6f21 --- /dev/null +++ b/tidepool-theme/login/register-clinician.ftl @@ -0,0 +1,127 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayMessage=!messagesPerField.existsError('firstName','lastName','email','username','password','password-confirm', 'terms') displayInfo=true; section> + <#if section = "header"> +
+ ${msg("registerTitleClinician")} +
+ <#elseif section = "form"> +
+ + + +
+
+ +
+
+ + + <#if messagesPerField.existsError('email')> +
+ ${kcSanitize(messagesPerField.get('email'))?no_esc} +
+ +
+
+ + <#if !realm.registrationEmailAsUsername> +
+
+ +
+
+ + + <#if messagesPerField.existsError('username')> +
+ ${kcSanitize(messagesPerField.get('username'))?no_esc} +
+ +
+
+ + + <#if passwordRequired??> +
+
+ +
+
+ + + <#if messagesPerField.existsError('password')> +
+ ${kcSanitize(messagesPerField.get('password'))?no_esc} +
+ +
+
+ +
+
+ +
+
+ + + <#if messagesPerField.existsError('password-confirm')> +
+ ${kcSanitize(messagesPerField.get('password-confirm'))?no_esc} +
+ +
+
+ + +
+
+
+
+ + +
+ <#if messagesPerField.existsError('terms')> + + ${kcSanitize(messagesPerField.get('terms'))?no_esc} + + +
+
+
+ + <#if recaptchaRequired??> +
+
+
+
+
+ + +
+
+ +
+
+
+ <#elseif section = "info" > +
+ ${msg("alreadyHaveAnAccount")} ${msg("doLogIn")} +
+
+ ${msg("needPersonalAccount")} ${msg("createAccountPrefix")} ${msg("createAccountPersonal")} ${msg("createAccountSuffix")} +
+ + \ No newline at end of file diff --git a/tidepool-theme/login/register.ftl b/tidepool-theme/login/register-personal.ftl similarity index 84% rename from tidepool-theme/login/register.ftl rename to tidepool-theme/login/register-personal.ftl index 2d7e1ad..86f4b80 100644 --- a/tidepool-theme/login/register.ftl +++ b/tidepool-theme/login/register-personal.ftl @@ -2,7 +2,7 @@ <@layout.registrationLayout displayMessage=!messagesPerField.existsError('firstName','lastName','email','username','password','password-confirm') displayInfo=true; section> <#if section = "header">
- ${msg("registerTitle")} + ${msg("registerTitlePersonal")}
<#elseif section = "form">
@@ -20,9 +20,9 @@ /> <#if messagesPerField.existsError('email')> - +
${kcSanitize(messagesPerField.get('email'))?no_esc} - +
@@ -39,9 +39,9 @@ /> <#if messagesPerField.existsError('username')> - +
${kcSanitize(messagesPerField.get('username'))?no_esc} - +
@@ -59,9 +59,9 @@ /> <#if messagesPerField.existsError('password')> - +
${kcSanitize(messagesPerField.get('password'))?no_esc} - +
@@ -78,9 +78,9 @@ /> <#if messagesPerField.existsError('password-confirm')> - +
${kcSanitize(messagesPerField.get('password-confirm'))?no_esc} - +
@@ -104,5 +104,8 @@
${msg("alreadyHaveAnAccount")} ${msg("doLogIn")}
+
+ ${msg("needClinicianAccount")} ${msg("createAccountPrefix")} ${msg("createAccountClinician")} ${msg("createAccountSuffix")} +
\ No newline at end of file diff --git a/tidepool-theme/login/resources/css/styles.css b/tidepool-theme/login/resources/css/styles.css index c852869..2892bd5 100644 --- a/tidepool-theme/login/resources/css/styles.css +++ b/tidepool-theme/login/resources/css/styles.css @@ -168,6 +168,7 @@ a.tp-btn-primary:hover, .tp-btn-primary:hover:enabled { .tp-form-error { color: #de514b; font-size: 16px; + margin-top: 5px; } .tp-alert { @@ -201,6 +202,7 @@ a.tp-btn-primary:hover, .tp-btn-primary:hover:enabled { font-size: 16px; font-weight: 500; text-align: center; + margin-bottom: 5px; } .login-pf-page #kc-registration i { @@ -285,7 +287,7 @@ a.tp-btn-primary:hover, .tp-btn-primary:hover:enabled { } .login-pf-page form { - max-width: 320px; + max-width: 360px; margin-left: auto; margin-right: auto; } @@ -296,13 +298,14 @@ a.tp-btn-primary:hover, .tp-btn-primary:hover:enabled { } .login-pf-page #kc-form, .login-pf-page #kc-register-form { - max-width: 320px; + max-width: 360px; } @media (min-width: 580px) { .login-pf-page #kc-form, .login-pf-page #kc-register-form { - padding-left: 90px; - padding-right: 90px; + max-width: 360px; + padding-left: 70px; + padding-right: 70px; } } @@ -337,7 +340,7 @@ a.tp-btn-primary:hover, .tp-btn-primary:hover:enabled { } .login-pf-page .form-group { - max-width: 320px; + max-width: 360px; margin: 15px auto 0; display: flow-root; } @@ -565,7 +568,7 @@ svg { .card-input-description { font-size: 18px; - color: #8c8c8c; + color: #6d6d6d; margin-bottom: 10px; } @@ -600,7 +603,15 @@ svg { } .clinician-terms-wrapper { - text-align: center; + margin-top: 10px; +} + +.clinician-terms-wrapper input { + vertical-align: top; +} + +.clinician-terms-wrapper label { + max-width: 95%; } .mail-icon { diff --git a/tidepool-theme/login/resources/img/tidepool-logo-880x96.png b/tidepool-theme/login/resources/img/tidepool-logo-880x96.png deleted file mode 100644 index 08b3860..0000000 Binary files a/tidepool-theme/login/resources/img/tidepool-logo-880x96.png and /dev/null differ diff --git a/tidepool-theme/login/resources/img/tidepool-logo-890x96.png b/tidepool-theme/login/resources/img/tidepool-logo-890x96.png new file mode 100644 index 0000000..e65af58 Binary files /dev/null and b/tidepool-theme/login/resources/img/tidepool-logo-890x96.png differ diff --git a/tidepool-theme/login/resources/img/tidepool-plus-logo-985x96.png b/tidepool-theme/login/resources/img/tidepool-plus-logo-985x96.png new file mode 100644 index 0000000..51aaa8a Binary files /dev/null and b/tidepool-theme/login/resources/img/tidepool-plus-logo-985x96.png differ diff --git a/tidepool-theme/login/template.ftl b/tidepool-theme/login/template.ftl index d3fb385..7cabe1e 100644 --- a/tidepool-theme/login/template.ftl +++ b/tidepool-theme/login/template.ftl @@ -67,7 +67,11 @@ <#nested "pre-header"> - + <#if role?? && role.hasClinicianRole()> + + <#else> + + <#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())> <#if displayRequiredFields> diff --git a/tidepool-theme/login/user_role_prompt.ftl b/tidepool-theme/login/user_role_prompt.ftl index 4368481..d85b004 100644 --- a/tidepool-theme/login/user_role_prompt.ftl +++ b/tidepool-theme/login/user_role_prompt.ftl @@ -1,10 +1,6 @@ <#import "template.ftl" as layout> <@layout.registrationLayout displayInfo=true; section> - <#if section = "header"> -<#-- --> -<#-- --> -<#-- --> - <#elseif section = "form"> + <#if section = "form">

Which kind of account do you need?

@@ -28,7 +24,7 @@
- You are a doctor, a clinic or other healthcare provider that wants to use Tidepool to help people in your care. + You are a doctor, nurse, or other clinical or administrative staff who wants to use Tidepool at your clinic.