diff --git a/cli/deployment/config.go b/cli/deployment/config.go index 0b4e0cd5b76cd..df739d02decea 100644 --- a/cli/deployment/config.go +++ b/cli/deployment/config.go @@ -254,6 +254,17 @@ func newConfig() *codersdk.DeploymentConfig { Flag: "oidc-username-field", Default: "preferred_username", }, + SignInText: &codersdk.DeploymentConfigField[string]{ + Name: "OpenID Connect sign in text", + Usage: "The text to show on the OpenID Connect sign in button", + Flag: "oidc-sign-in-text", + Default: "OpenID Connect", + }, + IconURL: &codersdk.DeploymentConfigField[string]{ + Name: "OpenID connect icon URL", + Usage: "URL pointing to the icon to use on the OepnID Connect login button", + Flag: "oidc-icon-url", + }, }, Telemetry: &codersdk.TelemetryConfig{ diff --git a/cli/server.go b/cli/server.go index c01ac903ac66d..b770f0af6cebf 100644 --- a/cli/server.go +++ b/cli/server.go @@ -552,6 +552,8 @@ func Server(vip *viper.Viper, newAPI func(context.Context, *coderd.Options) (*co EmailDomain: cfg.OIDC.EmailDomain.Value, AllowSignups: cfg.OIDC.AllowSignups.Value, UsernameField: cfg.OIDC.UsernameField.Value, + SignInText: cfg.OIDC.SignInText.Value, + IconURL: cfg.OIDC.IconURL.Value, } } diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index d72d9944533dd..92c01cc953958 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -148,6 +148,9 @@ Flags: --oidc-email-domain strings Email domains that clients logging in with OIDC must match. Consumes $CODER_OIDC_EMAIL_DOMAIN + --oidc-icon-url string URL pointing to the icon to use on the + OepnID Connect login button + Consumes $CODER_OIDC_ICON_URL --oidc-ignore-email-verified Ignore the email_verified claim from the upstream provider. Consumes $CODER_OIDC_IGNORE_EMAIL_VERIFIED @@ -157,6 +160,10 @@ Flags: OIDC. Consumes $CODER_OIDC_SCOPES (default [openid,profile,email]) + --oidc-sign-in-text string The text to show on the OpenID Connect + sign in button + Consumes $CODER_OIDC_SIGN_IN_TEXT + (default "OpenID Connect") --oidc-username-field string OIDC claim field to use as the username. Consumes $CODER_OIDC_USERNAME_FIELD (default "preferred_username") diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 71a7777b35dd9..4025b8338820a 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5444,17 +5444,25 @@ const docTemplate = `{ } } }, + "codersdk.AuthMethod": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } + }, "codersdk.AuthMethods": { "type": "object", "properties": { "github": { - "type": "boolean" + "$ref": "#/definitions/codersdk.AuthMethod" }, "oidc": { - "type": "boolean" + "$ref": "#/definitions/codersdk.OIDCAuthMethod" }, "password": { - "type": "boolean" + "$ref": "#/definitions/codersdk.AuthMethod" } } }, @@ -6626,6 +6634,20 @@ const docTemplate = `{ } } }, + "codersdk.OIDCAuthMethod": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "iconUrl": { + "type": "string" + }, + "signInText": { + "type": "string" + } + } + }, "codersdk.OIDCConfig": { "type": "object", "properties": { @@ -6641,6 +6663,9 @@ const docTemplate = `{ "email_domain": { "$ref": "#/definitions/codersdk.DeploymentConfigField-array_string" }, + "icon_url": { + "$ref": "#/definitions/codersdk.DeploymentConfigField-string" + }, "ignore_email_verified": { "$ref": "#/definitions/codersdk.DeploymentConfigField-bool" }, @@ -6650,6 +6675,9 @@ const docTemplate = `{ "scopes": { "$ref": "#/definitions/codersdk.DeploymentConfigField-array_string" }, + "sign_in_text": { + "$ref": "#/definitions/codersdk.DeploymentConfigField-string" + }, "username_field": { "$ref": "#/definitions/codersdk.DeploymentConfigField-string" } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index cb00f795253e7..cfa10bc868962 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -4825,17 +4825,25 @@ } } }, + "codersdk.AuthMethod": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } + }, "codersdk.AuthMethods": { "type": "object", "properties": { "github": { - "type": "boolean" + "$ref": "#/definitions/codersdk.AuthMethod" }, "oidc": { - "type": "boolean" + "$ref": "#/definitions/codersdk.OIDCAuthMethod" }, "password": { - "type": "boolean" + "$ref": "#/definitions/codersdk.AuthMethod" } } }, @@ -5927,6 +5935,20 @@ } } }, + "codersdk.OIDCAuthMethod": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "iconUrl": { + "type": "string" + }, + "signInText": { + "type": "string" + } + } + }, "codersdk.OIDCConfig": { "type": "object", "properties": { @@ -5942,6 +5964,9 @@ "email_domain": { "$ref": "#/definitions/codersdk.DeploymentConfigField-array_string" }, + "icon_url": { + "$ref": "#/definitions/codersdk.DeploymentConfigField-string" + }, "ignore_email_verified": { "$ref": "#/definitions/codersdk.DeploymentConfigField-bool" }, @@ -5951,6 +5976,9 @@ "scopes": { "$ref": "#/definitions/codersdk.DeploymentConfigField-array_string" }, + "sign_in_text": { + "$ref": "#/definitions/codersdk.DeploymentConfigField-string" + }, "username_field": { "$ref": "#/definitions/codersdk.DeploymentConfigField-string" } diff --git a/coderd/userauth.go b/coderd/userauth.go index 3fbc1c8f00bfa..7a62db27f5f11 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -51,10 +51,24 @@ type GithubOAuth2Config struct { // @Success 200 {object} codersdk.AuthMethods // @Router /users/authmethods [get] func (api *API) userAuthMethods(rw http.ResponseWriter, r *http.Request) { + var signInText string + var iconURL string + + if api.OIDCConfig != nil { + signInText = api.OIDCConfig.SignInText + } + if api.OIDCConfig != nil { + iconURL = api.OIDCConfig.IconURL + } + httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.AuthMethods{ - Password: true, - Github: api.GithubOAuth2Config != nil, - OIDC: api.OIDCConfig != nil, + Password: codersdk.AuthMethod{Enabled: true}, + Github: codersdk.AuthMethod{Enabled: api.GithubOAuth2Config != nil}, + OIDC: codersdk.OIDCAuthMethod{ + AuthMethod: codersdk.AuthMethod{Enabled: api.OIDCConfig != nil}, + SignInText: signInText, + IconURL: iconURL, + }, }) } @@ -215,6 +229,10 @@ type OIDCConfig struct { // UsernameField selects the claim field to be used as the created user's // username. UsernameField string + // SignInText is the text to display on the OIDC login button + SignInText string + // IconURL points to the URL of an icon to display on the OIDC login button + IconURL string } // @Summary OpenID Connect Callback diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index ea707289639ec..b03423aa032f4 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -77,8 +77,8 @@ func TestUserAuthMethods(t *testing.T) { methods, err := client.AuthMethods(ctx) require.NoError(t, err) - require.True(t, methods.Password) - require.False(t, methods.Github) + require.True(t, methods.Password.Enabled) + require.False(t, methods.Github.Enabled) }) t.Run("Github", func(t *testing.T) { t.Parallel() @@ -91,8 +91,8 @@ func TestUserAuthMethods(t *testing.T) { methods, err := client.AuthMethods(ctx) require.NoError(t, err) - require.True(t, methods.Password) - require.True(t, methods.Github) + require.True(t, methods.Password.Enabled) + require.True(t, methods.Github.Enabled) }) } diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 3d158fe44c047..b9778aba2aba6 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -200,6 +200,8 @@ type OIDCConfig struct { Scopes *DeploymentConfigField[[]string] `json:"scopes" typescript:",notnull"` IgnoreEmailVerified *DeploymentConfigField[bool] `json:"ignore_email_verified" typescript:",notnull"` UsernameField *DeploymentConfigField[string] `json:"username_field" typescript:",notnull"` + SignInText *DeploymentConfigField[string] `json:"sign_in_text" typescript:",notnull"` + IconURL *DeploymentConfigField[string] `json:"icon_url" typescript:",notnull"` } type TelemetryConfig struct { diff --git a/codersdk/users.go b/codersdk/users.go index 23b75bff340fd..f3ff654482458 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -105,11 +105,21 @@ type CreateOrganizationRequest struct { Name string `json:"name" validate:"required,username"` } -// AuthMethods contains whether authentication types are enabled or not. +// AuthMethods contains authentication method information like whether they are enabled or not or custom text, etc. type AuthMethods struct { - Password bool `json:"password"` - Github bool `json:"github"` - OIDC bool `json:"oidc"` + Password AuthMethod `json:"password"` + Github AuthMethod `json:"github"` + OIDC OIDCAuthMethod `json:"oidc"` +} + +type AuthMethod struct { + Enabled bool `json:"enabled"` +} + +type OIDCAuthMethod struct { + AuthMethod + SignInText string `json:"signInText"` + IconURL string `json:"iconUrl"` } // HasFirstUser returns whether the first user has been created. diff --git a/docs/admin/auth.md b/docs/admin/auth.md index 73d7bdd062801..983aeeae7f37f 100644 --- a/docs/admin/auth.md +++ b/docs/admin/auth.md @@ -131,6 +131,13 @@ CODER_OIDC_IGNORE_EMAIL_VERIFIED=true When a new user is created, the `preferred_username` claim becomes the username. If this claim is empty, the email address will be stripped of the domain, and become the username (e.g. `example@coder.com` becomes `example`). +If you'd like to change the OpenID Connect button text and/or icon, you can configure them like so: + +```console +CODER_OIDC_SIGN_IN_TEXT="Sign in with Gitea" +CODER_OIDC_ICON_URL=https://gitea.io/images/gitea.png +``` + ## SCIM (enterprise) Coder supports user provisioning and deprovisioning via SCIM 2.0 with header diff --git a/docs/api/general.md b/docs/api/general.md index cdfbcd77caf9c..eab1ffbd4c6e4 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -562,6 +562,17 @@ curl -X GET http://coder-server:8080/api/v2/config/deployment \ "usage": "string", "value": ["string"] }, + "icon_url": { + "default": "string", + "enterprise": true, + "flag": "string", + "hidden": true, + "name": "string", + "secret": true, + "shorthand": "string", + "usage": "string", + "value": "string" + }, "ignore_email_verified": { "default": true, "enterprise": true, @@ -595,6 +606,17 @@ curl -X GET http://coder-server:8080/api/v2/config/deployment \ "usage": "string", "value": ["string"] }, + "sign_in_text": { + "default": "string", + "enterprise": true, + "flag": "string", + "hidden": true, + "name": "string", + "secret": true, + "shorthand": "string", + "usage": "string", + "value": "string" + }, "username_field": { "default": "string", "enterprise": true, diff --git a/docs/api/schemas.md b/docs/api/schemas.md index e36c2d11c464b..b40144925b84f 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -663,23 +663,45 @@ | `audit_logs` | array of [codersdk.AuditLog](#codersdkauditlog) | false | | | | `count` | integer | false | | | +## codersdk.AuthMethod + +```json +{ + "enabled": true +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| --------- | ------- | -------- | ------------ | ----------- | +| `enabled` | boolean | false | | | + ## codersdk.AuthMethods ```json { - "github": true, - "oidc": true, - "password": true + "github": { + "enabled": true + }, + "oidc": { + "enabled": true, + "iconUrl": "string", + "signInText": "string" + }, + "password": { + "enabled": true + } } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| ---------- | ------- | -------- | ------------ | ----------- | -| `github` | boolean | false | | | -| `oidc` | boolean | false | | | -| `password` | boolean | false | | | +| Name | Type | Required | Restrictions | Description | +| ---------- | -------------------------------------------------- | -------- | ------------ | ----------- | +| `github` | [codersdk.AuthMethod](#codersdkauthmethod) | false | | | +| `oidc` | [codersdk.OIDCAuthMethod](#codersdkoidcauthmethod) | false | | | +| `password` | [codersdk.AuthMethod](#codersdkauthmethod) | false | | | ## codersdk.AuthorizationCheck @@ -1898,6 +1920,17 @@ CreateParameterRequest is a structure used to create a new parameter value for a "usage": "string", "value": ["string"] }, + "icon_url": { + "default": "string", + "enterprise": true, + "flag": "string", + "hidden": true, + "name": "string", + "secret": true, + "shorthand": "string", + "usage": "string", + "value": "string" + }, "ignore_email_verified": { "default": true, "enterprise": true, @@ -1931,6 +1964,17 @@ CreateParameterRequest is a structure used to create a new parameter value for a "usage": "string", "value": ["string"] }, + "sign_in_text": { + "default": "string", + "enterprise": true, + "flag": "string", + "hidden": true, + "name": "string", + "secret": true, + "shorthand": "string", + "usage": "string", + "value": "string" + }, "username_field": { "default": "string", "enterprise": true, @@ -3192,6 +3236,24 @@ CreateParameterRequest is a structure used to create a new parameter value for a | `client_secret` | [codersdk.DeploymentConfigField-string](#codersdkdeploymentconfigfield-string) | false | | | | `enterprise_base_url` | [codersdk.DeploymentConfigField-string](#codersdkdeploymentconfigfield-string) | false | | | +## codersdk.OIDCAuthMethod + +```json +{ + "enabled": true, + "iconUrl": "string", + "signInText": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------ | ------- | -------- | ------------ | ----------- | +| `enabled` | boolean | false | | | +| `iconUrl` | string | false | | | +| `signInText` | string | false | | | + ## codersdk.OIDCConfig ```json @@ -3240,6 +3302,17 @@ CreateParameterRequest is a structure used to create a new parameter value for a "usage": "string", "value": ["string"] }, + "icon_url": { + "default": "string", + "enterprise": true, + "flag": "string", + "hidden": true, + "name": "string", + "secret": true, + "shorthand": "string", + "usage": "string", + "value": "string" + }, "ignore_email_verified": { "default": true, "enterprise": true, @@ -3273,6 +3346,17 @@ CreateParameterRequest is a structure used to create a new parameter value for a "usage": "string", "value": ["string"] }, + "sign_in_text": { + "default": "string", + "enterprise": true, + "flag": "string", + "hidden": true, + "name": "string", + "secret": true, + "shorthand": "string", + "usage": "string", + "value": "string" + }, "username_field": { "default": "string", "enterprise": true, @@ -3295,9 +3379,11 @@ CreateParameterRequest is a structure used to create a new parameter value for a | `client_id` | [codersdk.DeploymentConfigField-string](#codersdkdeploymentconfigfield-string) | false | | | | `client_secret` | [codersdk.DeploymentConfigField-string](#codersdkdeploymentconfigfield-string) | false | | | | `email_domain` | [codersdk.DeploymentConfigField-array_string](#codersdkdeploymentconfigfield-array_string) | false | | | +| `icon_url` | [codersdk.DeploymentConfigField-string](#codersdkdeploymentconfigfield-string) | false | | | | `ignore_email_verified` | [codersdk.DeploymentConfigField-bool](#codersdkdeploymentconfigfield-bool) | false | | | | `issuer_url` | [codersdk.DeploymentConfigField-string](#codersdkdeploymentconfigfield-string) | false | | | | `scopes` | [codersdk.DeploymentConfigField-array_string](#codersdkdeploymentconfigfield-array_string) | false | | | +| `sign_in_text` | [codersdk.DeploymentConfigField-string](#codersdkdeploymentconfigfield-string) | false | | | | `username_field` | [codersdk.DeploymentConfigField-string](#codersdkdeploymentconfigfield-string) | false | | | ## codersdk.Organization diff --git a/docs/api/users.md b/docs/api/users.md index dd43765bc10cf..1fd8e73fc32e9 100644 --- a/docs/api/users.md +++ b/docs/api/users.md @@ -139,9 +139,17 @@ curl -X GET http://coder-server:8080/api/v2/users/authmethods \ ```json { - "github": true, - "oidc": true, - "password": true + "github": { + "enabled": true + }, + "oidc": { + "enabled": true, + "iconUrl": "string", + "signInText": "string" + }, + "password": { + "enabled": true + } } ``` diff --git a/docs/cli/coder_server.md b/docs/cli/coder_server.md index 7ec742525ec72..d25a72df239a2 100644 --- a/docs/cli/coder_server.md +++ b/docs/cli/coder_server.md @@ -72,12 +72,16 @@ coder server [flags] Consumes $CODER_OIDC_CLIENT_SECRET --oidc-email-domain strings Email domains that clients logging in with OIDC must match. Consumes $CODER_OIDC_EMAIL_DOMAIN + --oidc-icon-url string URL pointing to the icon to use on the OepnID Connect login button + Consumes $CODER_OIDC_ICON_URL --oidc-ignore-email-verified Ignore the email_verified claim from the upstream provider. Consumes $CODER_OIDC_IGNORE_EMAIL_VERIFIED --oidc-issuer-url string Issuer URL to use for Login with OIDC. Consumes $CODER_OIDC_ISSUER_URL --oidc-scopes strings Scopes to grant when authenticating with OIDC. Consumes $CODER_OIDC_SCOPES (default [openid,profile,email]) + --oidc-sign-in-text string The text to show on the OpenID Connect sign in button + Consumes $CODER_OIDC_SIGN_IN_TEXT (default "OpenID Connect") --oidc-username-field string OIDC claim field to use as the username. Consumes $CODER_OIDC_USERNAME_FIELD (default "preferred_username") --postgres-url string URL of a PostgreSQL database. If empty, PostgreSQL binaries will be downloaded from Maven (https://repo1.maven.org/maven2) and store all data in the config root. Access the built-in database with "coder server postgres-builtin-url". diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 25cfde097d203..048c269c0f5b5 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -88,11 +88,16 @@ export interface AuditLogsRequest extends Pagination { readonly q?: string } +// From codersdk/users.go +export interface AuthMethod { + readonly enabled: boolean +} + // From codersdk/users.go export interface AuthMethods { - readonly password: boolean - readonly github: boolean - readonly oidc: boolean + readonly password: AuthMethod + readonly github: AuthMethod + readonly oidc: OIDCAuthMethod } // From codersdk/authorization.go @@ -454,6 +459,12 @@ export interface OAuth2GithubConfig { readonly enterprise_base_url: DeploymentConfigField } +// From codersdk/users.go +export interface OIDCAuthMethod extends AuthMethod { + readonly signInText: string + readonly iconUrl: string +} + // From codersdk/deployment.go export interface OIDCConfig { readonly allow_signups: DeploymentConfigField @@ -464,6 +475,8 @@ export interface OIDCConfig { readonly scopes: DeploymentConfigField readonly ignore_email_verified: DeploymentConfigField readonly username_field: DeploymentConfigField + readonly sign_in_text: DeploymentConfigField + readonly icon_url: DeploymentConfigField } // From codersdk/organizations.go diff --git a/site/src/components/SignInForm/OAuthSignInForm.tsx b/site/src/components/SignInForm/OAuthSignInForm.tsx new file mode 100644 index 0000000000000..6a75dc99a8842 --- /dev/null +++ b/site/src/components/SignInForm/OAuthSignInForm.tsx @@ -0,0 +1,83 @@ +import Link from "@material-ui/core/Link" +import Button from "@material-ui/core/Button" +import GitHubIcon from "@material-ui/icons/GitHub" +import KeyIcon from "@material-ui/icons/VpnKey" +import Box from "@material-ui/core/Box" +import { Language } from "./SignInForm" +import { AuthMethods } from "../../api/typesGenerated" +import { FC } from "react" +import { makeStyles } from "@material-ui/core/styles" + +type OAuthSignInFormProps = { + isLoading: boolean + redirectTo: string + authMethods?: AuthMethods +} + +const useStyles = makeStyles(() => ({ + buttonIcon: { + width: 14, + height: 14, + }, +})) + +export const OAuthSignInForm: FC = ({ + isLoading, + redirectTo, + authMethods, +}) => { + const styles = useStyles() + + return ( + + {authMethods?.github.enabled && ( + + + + )} + + {authMethods?.oidc.enabled && ( + + + + )} + + ) +} diff --git a/site/src/components/SignInForm/PasswordSignInForm.tsx b/site/src/components/SignInForm/PasswordSignInForm.tsx new file mode 100644 index 0000000000000..ba6606f26da21 --- /dev/null +++ b/site/src/components/SignInForm/PasswordSignInForm.tsx @@ -0,0 +1,99 @@ +import { Stack } from "../Stack/Stack" +import { AlertBanner } from "../AlertBanner/AlertBanner" +import TextField from "@material-ui/core/TextField" +import { getFormHelpers, onChangeTrimmed } from "../../util/formUtils" +import { LoadingButton } from "../LoadingButton/LoadingButton" +import { Language, LoginErrors } from "./SignInForm" +import { FormikContextType, FormikTouched, useFormik } from "formik" +import * as Yup from "yup" +import { FC } from "react" +import { BuiltInAuthFormValues } from "./SignInForm.types" + +type PasswordSignInFormProps = { + loginErrors: Partial> + onSubmit: (credentials: { email: string; password: string }) => void + initialTouched?: FormikTouched + isLoading: boolean +} + +export const PasswordSignInForm: FC = ({ + loginErrors, + onSubmit, + initialTouched, + isLoading, +}) => { + const validationSchema = Yup.object({ + email: Yup.string() + .trim() + .email(Language.emailInvalid) + .required(Language.emailRequired), + password: Yup.string(), + }) + + const form: FormikContextType = + useFormik({ + initialValues: { + email: "", + password: "", + }, + validationSchema, + // The email field has an autoFocus, but users may log in with a button click. + // This is set to `false` in order to keep the autoFocus, validateOnChange + // and Formik experience friendly. Validation will kick in onChange (any + // field), or after a submission attempt. + validateOnBlur: false, + onSubmit, + initialTouched, + }) + const getFieldHelpers = getFormHelpers( + form, + loginErrors.authError, + ) + + return ( +
+ + {Object.keys(loginErrors).map( + (errorKey: string) => + Boolean(loginErrors[errorKey as LoginErrors]) && ( + + ), + )} + + +
+ + {isLoading ? "" : Language.passwordSignIn} + +
+
+
+ ) +} diff --git a/site/src/components/SignInForm/SignInForm.stories.tsx b/site/src/components/SignInForm/SignInForm.stories.tsx index 7de136157d3b6..02bcd1c53e3da 100644 --- a/site/src/components/SignInForm/SignInForm.stories.tsx +++ b/site/src/components/SignInForm/SignInForm.stories.tsx @@ -29,8 +29,9 @@ Loading.args = { ...SignedOut.args, isLoading: true, authMethods: { - github: true, - password: true, + password: { enabled: true }, + github: { enabled: true }, + oidc: { enabled: false, signInText: "", iconUrl: "" }, }, } @@ -99,9 +100,9 @@ export const WithGithub = Template.bind({}) WithGithub.args = { ...SignedOut.args, authMethods: { - password: true, - github: true, - oidc: false, + password: { enabled: true }, + github: { enabled: true }, + oidc: { enabled: false, signInText: "", iconUrl: "" }, }, } @@ -109,9 +110,9 @@ export const WithOIDC = Template.bind({}) WithOIDC.args = { ...SignedOut.args, authMethods: { - password: true, - github: false, - oidc: true, + password: { enabled: true }, + github: { enabled: false }, + oidc: { enabled: true, signInText: "", iconUrl: "" }, }, } @@ -119,8 +120,8 @@ export const WithGithubAndOIDC = Template.bind({}) WithGithubAndOIDC.args = { ...SignedOut.args, authMethods: { - password: true, - github: true, - oidc: true, + password: { enabled: true }, + github: { enabled: true }, + oidc: { enabled: true, signInText: "", iconUrl: "" }, }, } diff --git a/site/src/components/SignInForm/SignInForm.tsx b/site/src/components/SignInForm/SignInForm.tsx index fe631755926cf..d900372002747 100644 --- a/site/src/components/SignInForm/SignInForm.tsx +++ b/site/src/components/SignInForm/SignInForm.tsx @@ -1,29 +1,13 @@ -import Box from "@material-ui/core/Box" -import Button from "@material-ui/core/Button" -import Link from "@material-ui/core/Link" import { makeStyles } from "@material-ui/core/styles" -import TextField from "@material-ui/core/TextField" -import GitHubIcon from "@material-ui/icons/GitHub" -import KeyIcon from "@material-ui/icons/VpnKey" -import { Stack } from "components/Stack/Stack" -import { FormikContextType, FormikTouched, useFormik } from "formik" -import { FC } from "react" -import * as Yup from "yup" +import Typography from "@material-ui/core/Typography" +import { FormikTouched } from "formik" +import { FC, useState } from "react" import { AuthMethods } from "../../api/typesGenerated" -import { getFormHelpers, onChangeTrimmed } from "../../util/formUtils" -import { LoadingButton } from "./../LoadingButton/LoadingButton" -import { AlertBanner } from "components/AlertBanner/AlertBanner" import { useTranslation } from "react-i18next" - -/** - * BuiltInAuthFormValues describes a form using built-in (email/password) - * authentication. This form may not always be present depending on external - * auth providers available and administrative configurations - */ -interface BuiltInAuthFormValues { - email: string - password: string -} +import { Maybe } from "../Conditionals/Maybe" +import { PasswordSignInForm } from "./PasswordSignInForm" +import { OAuthSignInForm } from "./OAuthSignInForm" +import { BuiltInAuthFormValues } from "./SignInForm.types" export enum LoginErrors { AUTH_ERROR = "authError", @@ -48,14 +32,6 @@ export const Language = { oidcSignIn: "OpenID Connect", } -const validationSchema = Yup.object({ - email: Yup.string() - .trim() - .email(Language.emailInvalid) - .required(Language.emailRequired), - password: Yup.string(), -}) - const useStyles = makeStyles((theme) => ({ root: { width: "100%", @@ -71,10 +47,6 @@ const useStyles = makeStyles((theme) => ({ fontWeight: 600, }, }, - buttonIcon: { - width: 14, - height: 14, - }, divider: { paddingTop: theme.spacing(3), paddingBottom: theme.spacing(3), @@ -94,6 +66,12 @@ const useStyles = makeStyles((theme) => ({ fontSize: 12, letterSpacing: 1, }, + showPasswordLink: { + cursor: "pointer", + fontSize: 12, + color: theme.palette.text.secondary, + marginTop: 12, + }, })) export interface SignInFormProps { @@ -114,26 +92,14 @@ export const SignInForm: FC> = ({ onSubmit, initialTouched, }) => { - const styles = useStyles() - const form: FormikContextType = - useFormik({ - initialValues: { - email: "", - password: "", - }, - validationSchema, - // The email field has an autoFocus, but users may login with a button click. - // This is set to `false` in order to keep the autoFocus, validateOnChange - // and Formik experience friendly. Validation will kick in onChange (any - // field), or after a submission attempt. - validateOnBlur: false, - onSubmit, - initialTouched, - }) - const getFieldHelpers = getFormHelpers( - form, - loginErrors.authError, + const oAuthEnabled = Boolean( + authMethods?.github.enabled || authMethods?.oidc.enabled, ) + + // Hide password auth by default if any OAuth method is enabled + const [showPasswordAuth, setShowPasswordAuth] = useState(!oAuthEnabled) + const styles = useStyles() + const commonTranslation = useTranslation("common") const loginPageTranslation = useTranslation("loginPage") @@ -143,99 +109,36 @@ export const SignInForm: FC> = ({ {loginPageTranslation.t("signInTo")}{" "} {commonTranslation.t("coder")} -
- - {Object.keys(loginErrors).map( - (errorKey: string) => - Boolean(loginErrors[errorKey as LoginErrors]) && ( - - ), - )} - - -
- - {isLoading ? "" : Language.passwordSignIn} - -
-
-
- {(authMethods?.github || authMethods?.oidc) && ( -
-
-
-
Or
-
-
- - - {authMethods.github && ( - - - - )} - - {authMethods.oidc && ( - - - - )} - + + + + +
+
+
Or
+
- )} + + + + + + setShowPasswordAuth(true)} + > + {loginPageTranslation.t("showPassword")} + +
) } diff --git a/site/src/components/SignInForm/SignInForm.types.ts b/site/src/components/SignInForm/SignInForm.types.ts new file mode 100644 index 0000000000000..8965458bd8953 --- /dev/null +++ b/site/src/components/SignInForm/SignInForm.types.ts @@ -0,0 +1,9 @@ +/** + * BuiltInAuthFormValues describes a form using built-in (email/password) + * authentication. This form may not always be present depending on external + * auth providers available and administrative configurations + */ +export interface BuiltInAuthFormValues { + email: string + password: string +} diff --git a/site/src/i18n/en/loginPage.json b/site/src/i18n/en/loginPage.json index 329dc925a2e8e..187f6a4d141d6 100644 --- a/site/src/i18n/en/loginPage.json +++ b/site/src/i18n/en/loginPage.json @@ -1,3 +1,4 @@ { - "signInTo": "Sign in to" + "signInTo": "Sign in to", + "showPassword": "Show password login" } diff --git a/site/src/pages/LoginPage/LoginPage.test.tsx b/site/src/pages/LoginPage/LoginPage.test.tsx index 19372d002dbd5..f272f9198c5ac 100644 --- a/site/src/pages/LoginPage/LoginPage.test.tsx +++ b/site/src/pages/LoginPage/LoginPage.test.tsx @@ -10,6 +10,7 @@ import { } from "../../testHelpers/renderHelpers" import { server } from "../../testHelpers/server" import { LoginPage } from "./LoginPage" +import * as TypesGen from "api/typesGenerated" describe("LoginPage", () => { beforeEach(() => { @@ -80,16 +81,16 @@ describe("LoginPage", () => { }) it("shows github authentication when enabled", async () => { + const authMethods: TypesGen.AuthMethods = { + password: { enabled: true }, + github: { enabled: true }, + oidc: { enabled: true, signInText: "", iconUrl: "" }, + } + // Given server.use( rest.get("/api/v2/users/authmethods", async (req, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ - password: true, - github: true, - }), - ) + return res(ctx.status(200), ctx.json(authMethods)) }), ) @@ -97,7 +98,7 @@ describe("LoginPage", () => { render() // Then - await screen.findByText(Language.passwordSignIn) + expect(screen.queryByText(Language.passwordSignIn)).not.toBeInTheDocument() await screen.findByText(Language.githubSignIn) }) @@ -120,4 +121,32 @@ describe("LoginPage", () => { // Then await screen.findByText("Setup") }) + + it("hides password authentication if OIDC/GitHub is enabled and displays on click", async () => { + const authMethods: TypesGen.AuthMethods = { + password: { enabled: true }, + github: { enabled: true }, + oidc: { enabled: true, signInText: "", iconUrl: "" }, + } + + // Given + server.use( + rest.get("/api/v2/users/authmethods", async (req, res, ctx) => { + return res(ctx.status(200), ctx.json(authMethods)) + }), + ) + + // When + render() + + // Then + expect(screen.queryByText(Language.passwordSignIn)).not.toBeInTheDocument() + await screen.findByText(Language.githubSignIn) + + const showPasswordAuthLink = screen.getByText("Show password login") + await userEvent.click(showPasswordAuthLink) + + await screen.findByText(Language.passwordSignIn) + await screen.findByText(Language.githubSignIn) + }) }) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 6456fb3abf442..9a40ddcbdc005 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -646,9 +646,9 @@ export const MockUserAgent: Types.UserAgent = { } export const MockAuthMethods: TypesGen.AuthMethods = { - password: true, - github: false, - oidc: false, + password: { enabled: true }, + github: { enabled: false }, + oidc: { enabled: false, signInText: "", iconUrl: "" }, } export const MockGitSSHKey: TypesGen.GitSSHKey = {