feat: Allow hiding password auth, changing OpenID Connect text and OpenID Connect icon (#5101)

* Allow hiding password entry, changing OpenID Connect text and OpenID Connect icon

* Docs

* Cleaning

* Fix Prettier and Go test and TS compile error

* Fix LoginPage test

* Prettier

* Fix storybook

* Add query param to un-hide password auth

* Cleaning

* Hide password by default when OIDC enabled

* Ran prettier, updated goldenfiles and ran "make gen"

* Fixed and added LoginPage test

* Ran prettier

* PR Feedback and split up SignInForm.tsx

* Updated golden files

* Fix auto-genned-files

* make gen -B

* Revert provisioner files?

* Fix lint error

---------

Co-authored-by: Kyle Carberry <kyle@coder.com>
This commit is contained in:
Arthur Normand 2023-01-31 13:33:25 -05:00 committed by GitHub
parent 480f3b6e43
commit 69fce0488e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 572 additions and 201 deletions

View File

@ -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{

View File

@ -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,
}
}

View File

@ -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")

34
coderd/apidoc/docs.go generated
View File

@ -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"
}

View File

@ -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"
}

View File

@ -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

View File

@ -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)
})
}

View File

@ -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 {

View File

@ -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.

View File

@ -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

View File

@ -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,

View File

@ -663,23 +663,45 @@
| `audit_logs` | array of [codersdk.AuditLog](#codersdkauditlog) | false | | |
| `count` | integer | false | | |
## codersdk.AuthMethods
## codersdk.AuthMethod
```json
{
"github": true,
"oidc": true,
"password": true
"enabled": true
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ---------- | ------- | -------- | ------------ | ----------- |
| `github` | boolean | false | | |
| `oidc` | boolean | false | | |
| `password` | boolean | false | | |
| Name | Type | Required | Restrictions | Description |
| --------- | ------- | -------- | ------------ | ----------- |
| `enabled` | boolean | false | | |
## codersdk.AuthMethods
```json
{
"github": {
"enabled": true
},
"oidc": {
"enabled": true,
"iconUrl": "string",
"signInText": "string"
},
"password": {
"enabled": true
}
}
```
### Properties
| 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

View File

@ -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
}
}
```

View File

@ -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".

View File

@ -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<string>
}
// 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<boolean>
@ -464,6 +475,8 @@ export interface OIDCConfig {
readonly scopes: DeploymentConfigField<string[]>
readonly ignore_email_verified: DeploymentConfigField<boolean>
readonly username_field: DeploymentConfigField<string>
readonly sign_in_text: DeploymentConfigField<string>
readonly icon_url: DeploymentConfigField<string>
}
// From codersdk/organizations.go

View File

@ -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<OAuthSignInFormProps> = ({
isLoading,
redirectTo,
authMethods,
}) => {
const styles = useStyles()
return (
<Box display="grid" gridGap="16px">
{authMethods?.github.enabled && (
<Link
underline="none"
href={`/api/v2/users/oauth2/github/callback?redirect=${encodeURIComponent(
redirectTo,
)}`}
>
<Button
startIcon={<GitHubIcon className={styles.buttonIcon} />}
disabled={isLoading}
fullWidth
type="submit"
variant="contained"
>
{Language.githubSignIn}
</Button>
</Link>
)}
{authMethods?.oidc.enabled && (
<Link
underline="none"
href={`/api/v2/users/oidc/callback?redirect=${encodeURIComponent(
redirectTo,
)}`}
>
<Button
startIcon={
authMethods.oidc.iconUrl ? (
<img
alt="Open ID Connect icon"
src={authMethods.oidc.iconUrl}
width="24"
height="24"
/>
) : (
<KeyIcon className={styles.buttonIcon} />
)
}
disabled={isLoading}
fullWidth
type="submit"
variant="contained"
>
{authMethods.oidc.signInText || Language.oidcSignIn}
</Button>
</Link>
)}
</Box>
)
}

View File

@ -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<Record<LoginErrors, Error | unknown>>
onSubmit: (credentials: { email: string; password: string }) => void
initialTouched?: FormikTouched<BuiltInAuthFormValues>
isLoading: boolean
}
export const PasswordSignInForm: FC<PasswordSignInFormProps> = ({
loginErrors,
onSubmit,
initialTouched,
isLoading,
}) => {
const validationSchema = Yup.object({
email: Yup.string()
.trim()
.email(Language.emailInvalid)
.required(Language.emailRequired),
password: Yup.string(),
})
const form: FormikContextType<BuiltInAuthFormValues> =
useFormik<BuiltInAuthFormValues>({
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<BuiltInAuthFormValues>(
form,
loginErrors.authError,
)
return (
<form onSubmit={form.handleSubmit}>
<Stack>
{Object.keys(loginErrors).map(
(errorKey: string) =>
Boolean(loginErrors[errorKey as LoginErrors]) && (
<AlertBanner
key={errorKey}
severity="error"
error={loginErrors[errorKey as LoginErrors]}
text={Language.errorMessages[errorKey as LoginErrors]}
/>
),
)}
<TextField
{...getFieldHelpers("email")}
onChange={onChangeTrimmed(form)}
autoFocus
autoComplete="email"
fullWidth
label={Language.emailLabel}
type="email"
variant="outlined"
/>
<TextField
{...getFieldHelpers("password")}
autoComplete="current-password"
fullWidth
id="password"
label={Language.passwordLabel}
type="password"
variant="outlined"
/>
<div>
<LoadingButton
loading={isLoading}
fullWidth
type="submit"
variant="contained"
>
{isLoading ? "" : Language.passwordSignIn}
</LoadingButton>
</div>
</Stack>
</form>
)
}

View File

@ -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: "" },
},
}

View File

@ -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<React.PropsWithChildren<SignInFormProps>> = ({
onSubmit,
initialTouched,
}) => {
const styles = useStyles()
const form: FormikContextType<BuiltInAuthFormValues> =
useFormik<BuiltInAuthFormValues>({
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<BuiltInAuthFormValues>(
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<React.PropsWithChildren<SignInFormProps>> = ({
{loginPageTranslation.t("signInTo")}{" "}
<strong>{commonTranslation.t("coder")}</strong>
</h1>
<form onSubmit={form.handleSubmit}>
<Stack>
{Object.keys(loginErrors).map(
(errorKey: string) =>
Boolean(loginErrors[errorKey as LoginErrors]) && (
<AlertBanner
key={errorKey}
severity="error"
error={loginErrors[errorKey as LoginErrors]}
text={Language.errorMessages[errorKey as LoginErrors]}
/>
),
)}
<TextField
{...getFieldHelpers("email")}
onChange={onChangeTrimmed(form)}
autoFocus
autoComplete="email"
fullWidth
label={Language.emailLabel}
type="email"
variant="outlined"
/>
<TextField
{...getFieldHelpers("password")}
autoComplete="current-password"
fullWidth
id="password"
label={Language.passwordLabel}
type="password"
variant="outlined"
/>
<div>
<LoadingButton
loading={isLoading}
fullWidth
type="submit"
variant="contained"
>
{isLoading ? "" : Language.passwordSignIn}
</LoadingButton>
</div>
</Stack>
</form>
{(authMethods?.github || authMethods?.oidc) && (
<div>
<div className={styles.divider}>
<div className={styles.dividerLine} />
<div className={styles.dividerLabel}>Or</div>
<div className={styles.dividerLine} />
</div>
<Box display="grid" gridGap="16px">
{authMethods.github && (
<Link
underline="none"
href={`/api/v2/users/oauth2/github/callback?redirect=${encodeURIComponent(
redirectTo,
)}`}
>
<Button
startIcon={<GitHubIcon className={styles.buttonIcon} />}
disabled={isLoading}
fullWidth
type="submit"
variant="contained"
>
{Language.githubSignIn}
</Button>
</Link>
)}
{authMethods.oidc && (
<Link
underline="none"
href={`/api/v2/users/oidc/callback?redirect=${encodeURIComponent(
redirectTo,
)}`}
>
<Button
startIcon={<KeyIcon className={styles.buttonIcon} />}
disabled={isLoading}
fullWidth
type="submit"
variant="contained"
>
{Language.oidcSignIn}
</Button>
</Link>
)}
</Box>
<Maybe condition={showPasswordAuth}>
<PasswordSignInForm
loginErrors={loginErrors}
onSubmit={onSubmit}
initialTouched={initialTouched}
isLoading={isLoading}
/>
</Maybe>
<Maybe condition={showPasswordAuth && oAuthEnabled}>
<div className={styles.divider}>
<div className={styles.dividerLine} />
<div className={styles.dividerLabel}>Or</div>
<div className={styles.dividerLine} />
</div>
)}
</Maybe>
<Maybe condition={oAuthEnabled}>
<OAuthSignInForm
isLoading={isLoading}
redirectTo={redirectTo}
authMethods={authMethods}
/>
</Maybe>
<Maybe condition={!showPasswordAuth}>
<Typography
className={styles.showPasswordLink}
onClick={() => setShowPasswordAuth(true)}
>
{loginPageTranslation.t("showPassword")}
</Typography>
</Maybe>
</div>
)
}

View File

@ -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
}

View File

@ -1,3 +1,4 @@
{
"signInTo": "Sign in to"
"signInTo": "Sign in to",
"showPassword": "Show password login"
}

View File

@ -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(<LoginPage />)
// 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(<LoginPage />)
// 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)
})
})

View File

@ -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 = {