feat: specify a custom "terms of service" link (#13068)

This commit is contained in:
Kayla Washburn-Love 2024-04-25 16:36:51 -06:00 committed by GitHub
parent 341114a020
commit 74f27719b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 131 additions and 16 deletions

View File

@ -60,6 +60,10 @@ OPTIONS:
--support-links struct[[]codersdk.LinkConfig], $CODER_SUPPORT_LINKS --support-links struct[[]codersdk.LinkConfig], $CODER_SUPPORT_LINKS
Support links to display in the top right drop down menu. Support links to display in the top right drop down menu.
--terms-of-service-url string, $CODER_TERMS_OF_SERVICE_URL
A URL to an external Terms of Service that must be accepted by users
when logging in.
--update-check bool, $CODER_UPDATE_CHECK (default: false) --update-check bool, $CODER_UPDATE_CHECK (default: false)
Periodically check for new releases of Coder and inform the owner. The Periodically check for new releases of Coder and inform the owner. The
check is performed once per day. check is performed once per day.

View File

@ -414,6 +414,10 @@ inMemoryDatabase: false
# Type of auth to use when connecting to postgres. # Type of auth to use when connecting to postgres.
# (default: password, type: enum[password\|awsiamrds]) # (default: password, type: enum[password\|awsiamrds])
pgAuth: password pgAuth: password
# A URL to an external Terms of Service that must be accepted by users when
# logging in.
# (default: <unset>, type: string)
termsOfServiceURL: ""
# The algorithm to use for generating ssh keys. Accepted values are "ed25519", # The algorithm to use for generating ssh keys. Accepted values are "ed25519",
# "ecdsa", or "rsa4096". # "ecdsa", or "rsa4096".
# (default: ed25519, type: string) # (default: ed25519, type: string)

6
coderd/apidoc/docs.go generated
View File

@ -8446,6 +8446,9 @@ const docTemplate = `{
}, },
"password": { "password": {
"$ref": "#/definitions/codersdk.AuthMethod" "$ref": "#/definitions/codersdk.AuthMethod"
},
"terms_of_service_url": {
"type": "string"
} }
} }
}, },
@ -9408,6 +9411,9 @@ const docTemplate = `{
"telemetry": { "telemetry": {
"$ref": "#/definitions/codersdk.TelemetryConfig" "$ref": "#/definitions/codersdk.TelemetryConfig"
}, },
"terms_of_service_url": {
"type": "string"
},
"tls": { "tls": {
"$ref": "#/definitions/codersdk.TLSConfig" "$ref": "#/definitions/codersdk.TLSConfig"
}, },

View File

@ -7515,6 +7515,9 @@
}, },
"password": { "password": {
"$ref": "#/definitions/codersdk.AuthMethod" "$ref": "#/definitions/codersdk.AuthMethod"
},
"terms_of_service_url": {
"type": "string"
} }
} }
}, },
@ -8413,6 +8416,9 @@
"telemetry": { "telemetry": {
"$ref": "#/definitions/codersdk.TelemetryConfig" "$ref": "#/definitions/codersdk.TelemetryConfig"
}, },
"terms_of_service_url": {
"type": "string"
},
"tls": { "tls": {
"$ref": "#/definitions/codersdk.TLSConfig" "$ref": "#/definitions/codersdk.TLSConfig"
}, },

View File

@ -472,6 +472,7 @@ func (api *API) userAuthMethods(rw http.ResponseWriter, r *http.Request) {
} }
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.AuthMethods{ httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.AuthMethods{
TermsOfServiceURL: api.DeploymentValues.TermsOfServiceURL.Value(),
Password: codersdk.AuthMethod{ Password: codersdk.AuthMethod{
Enabled: !api.DeploymentValues.DisablePasswordAuth.Value(), Enabled: !api.DeploymentValues.DisablePasswordAuth.Value(),
}, },

View File

@ -200,6 +200,7 @@ type DeploymentValues struct {
AllowWorkspaceRenames serpent.Bool `json:"allow_workspace_renames,omitempty" typescript:",notnull"` AllowWorkspaceRenames serpent.Bool `json:"allow_workspace_renames,omitempty" typescript:",notnull"`
Healthcheck HealthcheckConfig `json:"healthcheck,omitempty" typescript:",notnull"` Healthcheck HealthcheckConfig `json:"healthcheck,omitempty" typescript:",notnull"`
CLIUpgradeMessage serpent.String `json:"cli_upgrade_message,omitempty" typescript:",notnull"` CLIUpgradeMessage serpent.String `json:"cli_upgrade_message,omitempty" typescript:",notnull"`
TermsOfServiceURL serpent.String `json:"terms_of_service_url,omitempty" typescript:",notnull"`
Config serpent.YAMLConfigPath `json:"config,omitempty" typescript:",notnull"` Config serpent.YAMLConfigPath `json:"config,omitempty" typescript:",notnull"`
WriteConfig serpent.Bool `json:"write_config,omitempty" typescript:",notnull"` WriteConfig serpent.Bool `json:"write_config,omitempty" typescript:",notnull"`
@ -1683,6 +1684,14 @@ when required by your organization's security policy.`,
YAML: "secureAuthCookie", YAML: "secureAuthCookie",
Annotations: serpent.Annotations{}.Mark(annotationExternalProxies, "true"), Annotations: serpent.Annotations{}.Mark(annotationExternalProxies, "true"),
}, },
{
Name: "Terms of Service URL",
Description: "A URL to an external Terms of Service that must be accepted by users when logging in.",
Flag: "terms-of-service-url",
Env: "CODER_TERMS_OF_SERVICE_URL",
YAML: "termsOfServiceURL",
Value: &c.TermsOfServiceURL,
},
{ {
Name: "Strict-Transport-Security", Name: "Strict-Transport-Security",
Description: "Controls if the 'Strict-Transport-Security' header is set on all static file responses. " + Description: "Controls if the 'Strict-Transport-Security' header is set on all static file responses. " +

View File

@ -209,9 +209,10 @@ type CreateOrganizationRequest struct {
// AuthMethods contains authentication method information like whether they are enabled or not or custom text, etc. // AuthMethods contains authentication method information like whether they are enabled or not or custom text, etc.
type AuthMethods struct { type AuthMethods struct {
Password AuthMethod `json:"password"` TermsOfServiceURL string `json:"terms_of_service_url,omitempty"`
Github AuthMethod `json:"github"` Password AuthMethod `json:"password"`
OIDC OIDCAuthMethod `json:"oidc"` Github AuthMethod `json:"github"`
OIDC OIDCAuthMethod `json:"oidc"`
} }
type AuthMethod struct { type AuthMethod struct {

1
docs/api/general.md generated
View File

@ -377,6 +377,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
"user": {} "user": {}
} }
}, },
"terms_of_service_url": "string",
"tls": { "tls": {
"address": { "address": {
"host": "string", "host": "string",

17
docs/api/schemas.md generated
View File

@ -1040,17 +1040,19 @@
}, },
"password": { "password": {
"enabled": true "enabled": true
} },
"terms_of_service_url": "string"
} }
``` ```
### Properties ### Properties
| Name | Type | Required | Restrictions | Description | | Name | Type | Required | Restrictions | Description |
| ---------- | -------------------------------------------------- | -------- | ------------ | ----------- | | ---------------------- | -------------------------------------------------- | -------- | ------------ | ----------- |
| `github` | [codersdk.AuthMethod](#codersdkauthmethod) | false | | | | `github` | [codersdk.AuthMethod](#codersdkauthmethod) | false | | |
| `oidc` | [codersdk.OIDCAuthMethod](#codersdkoidcauthmethod) | false | | | | `oidc` | [codersdk.OIDCAuthMethod](#codersdkoidcauthmethod) | false | | |
| `password` | [codersdk.AuthMethod](#codersdkauthmethod) | false | | | | `password` | [codersdk.AuthMethod](#codersdkauthmethod) | false | | |
| `terms_of_service_url` | string | false | | |
## codersdk.AuthorizationCheck ## codersdk.AuthorizationCheck
@ -2102,6 +2104,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"user": {} "user": {}
} }
}, },
"terms_of_service_url": "string",
"tls": { "tls": {
"address": { "address": {
"host": "string", "host": "string",
@ -2474,6 +2477,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"user": {} "user": {}
} }
}, },
"terms_of_service_url": "string",
"tls": { "tls": {
"address": { "address": {
"host": "string", "host": "string",
@ -2562,6 +2566,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
| `support` | [codersdk.SupportConfig](#codersdksupportconfig) | false | | | | `support` | [codersdk.SupportConfig](#codersdksupportconfig) | false | | |
| `swagger` | [codersdk.SwaggerConfig](#codersdkswaggerconfig) | false | | | | `swagger` | [codersdk.SwaggerConfig](#codersdkswaggerconfig) | false | | |
| `telemetry` | [codersdk.TelemetryConfig](#codersdktelemetryconfig) | false | | | | `telemetry` | [codersdk.TelemetryConfig](#codersdktelemetryconfig) | false | | |
| `terms_of_service_url` | string | false | | |
| `tls` | [codersdk.TLSConfig](#codersdktlsconfig) | false | | | | `tls` | [codersdk.TLSConfig](#codersdktlsconfig) | false | | |
| `trace` | [codersdk.TraceConfig](#codersdktraceconfig) | false | | | | `trace` | [codersdk.TraceConfig](#codersdktraceconfig) | false | | |
| `update_check` | boolean | false | | | | `update_check` | boolean | false | | |

3
docs/api/users.md generated
View File

@ -157,7 +157,8 @@ curl -X GET http://coder-server:8080/api/v2/users/authmethods \
}, },
"password": { "password": {
"enabled": true "enabled": true
} },
"terms_of_service_url": "string"
} }
``` ```

10
docs/cli/server.md generated
View File

@ -928,6 +928,16 @@ Type of auth to use when connecting to postgres.
Controls if the 'Secure' property is set on browser session cookies. Controls if the 'Secure' property is set on browser session cookies.
### --terms-of-service-url
| | |
| ----------- | ---------------------------------------- |
| Type | <code>string</code> |
| Environment | <code>$CODER_TERMS_OF_SERVICE_URL</code> |
| YAML | <code>termsOfServiceURL</code> |
A URL to an external Terms of Service that must be accepted by users when logging in.
### --strict-transport-security ### --strict-transport-security
| | | | | |

View File

@ -61,6 +61,10 @@ OPTIONS:
--support-links struct[[]codersdk.LinkConfig], $CODER_SUPPORT_LINKS --support-links struct[[]codersdk.LinkConfig], $CODER_SUPPORT_LINKS
Support links to display in the top right drop down menu. Support links to display in the top right drop down menu.
--terms-of-service-url string, $CODER_TERMS_OF_SERVICE_URL
A URL to an external Terms of Service that must be accepted by users
when logging in.
--update-check bool, $CODER_UPDATE_CHECK (default: false) --update-check bool, $CODER_UPDATE_CHECK (default: false)
Periodically check for new releases of Coder and inform the owner. The Periodically check for new releases of Coder and inform the owner. The
check is performed once per day. check is performed once per day.

View File

@ -124,6 +124,7 @@ export interface AuthMethod {
// From codersdk/users.go // From codersdk/users.go
export interface AuthMethods { export interface AuthMethods {
readonly terms_of_service_url?: string;
readonly password: AuthMethod; readonly password: AuthMethod;
readonly github: AuthMethod; readonly github: AuthMethod;
readonly oidc: OIDCAuthMethod; readonly oidc: OIDCAuthMethod;
@ -445,6 +446,7 @@ export interface DeploymentValues {
readonly allow_workspace_renames?: boolean; readonly allow_workspace_renames?: boolean;
readonly healthcheck?: HealthcheckConfig; readonly healthcheck?: HealthcheckConfig;
readonly cli_upgrade_message?: string; readonly cli_upgrade_message?: string;
readonly terms_of_service_url?: string;
readonly config?: string; readonly config?: string;
readonly write_config?: boolean; readonly write_config?: boolean;
readonly address?: string; readonly address?: string;

View File

@ -3,6 +3,8 @@ import {
MockAuthMethodsAll, MockAuthMethodsAll,
MockAuthMethodsExternal, MockAuthMethodsExternal,
MockAuthMethodsPasswordOnly, MockAuthMethodsPasswordOnly,
MockAuthMethodsPasswordTermsOfService,
MockBuildInfo,
mockApiError, mockApiError,
} from "testHelpers/entities"; } from "testHelpers/entities";
import { LoginPageView } from "./LoginPageView"; import { LoginPageView } from "./LoginPageView";
@ -10,6 +12,9 @@ import { LoginPageView } from "./LoginPageView";
const meta: Meta<typeof LoginPageView> = { const meta: Meta<typeof LoginPageView> = {
title: "pages/LoginPage", title: "pages/LoginPage",
component: LoginPageView, component: LoginPageView,
args: {
buildInfo: MockBuildInfo,
},
}; };
export default meta; export default meta;
@ -33,6 +38,12 @@ export const WithAllAuthMethods: Story = {
}, },
}; };
export const WithTermsOfService: Story = {
args: {
authMethods: MockAuthMethodsPasswordTermsOfService,
},
};
export const AuthError: Story = { export const AuthError: Story = {
args: { args: {
error: mockApiError({ error: mockApiError({
@ -53,6 +64,7 @@ export const ExternalAuthError: Story = {
export const LoadingAuthMethods: Story = { export const LoadingAuthMethods: Story = {
args: { args: {
isLoading: true,
authMethods: undefined, authMethods: undefined,
}, },
}; };

View File

@ -1,5 +1,6 @@
import type { Interpolation, Theme } from "@emotion/react"; import type { Interpolation, Theme } from "@emotion/react";
import type { FC } from "react"; import Button from "@mui/material/Button";
import { type FC, useState } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import type { AuthMethods, BuildInfoResponse } from "api/typesGenerated"; import type { AuthMethods, BuildInfoResponse } from "api/typesGenerated";
import { CoderIcon } from "components/Icons/CoderIcon"; import { CoderIcon } from "components/Icons/CoderIcon";
@ -7,6 +8,7 @@ import { Loader } from "components/Loader/Loader";
import { getApplicationName, getLogoURL } from "utils/appearance"; import { getApplicationName, getLogoURL } from "utils/appearance";
import { retrieveRedirect } from "utils/redirect"; import { retrieveRedirect } from "utils/redirect";
import { SignInForm } from "./SignInForm"; import { SignInForm } from "./SignInForm";
import { TermsOfServiceLink } from "./TermsOfServiceLink";
export interface LoginPageViewProps { export interface LoginPageViewProps {
authMethods: AuthMethods | undefined; authMethods: AuthMethods | undefined;
@ -49,12 +51,21 @@ export const LoginPageView: FC<LoginPageViewProps> = ({
<CoderIcon fill="white" opacity={1} css={styles.icon} /> <CoderIcon fill="white" opacity={1} css={styles.icon} />
); );
const [tosAccepted, setTosAccepted] = useState(false);
const tosAcceptanceRequired =
authMethods?.terms_of_service_url && !tosAccepted;
return ( return (
<div css={styles.root}> <div css={styles.root}>
<div css={styles.container}> <div css={styles.container}>
{applicationLogo} {applicationLogo}
{isLoading ? ( {isLoading ? (
<Loader /> <Loader />
) : tosAcceptanceRequired ? (
<>
<TermsOfServiceLink url={authMethods.terms_of_service_url} />
<Button onClick={() => setTosAccepted(true)}>I agree</Button>
</>
) : ( ) : (
<SignInForm <SignInForm
authMethods={authMethods} authMethods={authMethods}
@ -70,6 +81,12 @@ export const LoginPageView: FC<LoginPageViewProps> = ({
Copyright &copy; {new Date().getFullYear()} Coder Technologies, Inc. Copyright &copy; {new Date().getFullYear()} Coder Technologies, Inc.
</div> </div>
<div>{buildInfo?.version}</div> <div>{buildInfo?.version}</div>
{tosAccepted && (
<TermsOfServiceLink
url={authMethods?.terms_of_service_url}
css={{ fontSize: 12 }}
/>
)}
</footer> </footer>
</div> </div>
</div> </div>

View File

@ -110,7 +110,7 @@ export const SignInForm: FC<SignInFormProps> = ({
{passwordEnabled && oAuthEnabled && ( {passwordEnabled && oAuthEnabled && (
<div css={styles.divider}> <div css={styles.divider}>
<div css={styles.dividerLine} /> <div css={styles.dividerLine} />
<div css={styles.dividerLabel}>Or</div> <div css={styles.dividerLabel}>or</div>
<div css={styles.dividerLine} /> <div css={styles.dividerLine} />
</div> </div>
)} )}

View File

@ -0,0 +1,28 @@
import LaunchIcon from "@mui/icons-material/LaunchOutlined";
import Link from "@mui/material/Link";
import type { FC } from "react";
interface TermsOfServiceLinkProps {
className?: string;
url?: string;
}
export const TermsOfServiceLink: FC<TermsOfServiceLinkProps> = ({
className,
url,
}) => {
return (
<div css={{ paddingTop: 12, fontSize: 16 }} className={className}>
By continuing, you agree to the{" "}
<Link
css={{ fontWeight: 500, textWrap: "nowrap" }}
href={url}
target="_blank"
rel="noreferrer"
>
Terms of Service&nbsp;
<LaunchIcon css={{ fontSize: 12 }} />
</Link>
</div>
);
};

View File

@ -136,10 +136,7 @@ export const SingleSignOnSection: FC<SingleSignOnSectionProps> = ({
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const authList = Object.values( const noSsoEnabled = !authMethods.github.enabled && !authMethods.oidc.enabled;
authMethods,
) as (typeof authMethods)[keyof typeof authMethods][];
const noSsoEnabled = !authList.some((method) => method.enabled);
return ( return (
<> <>

View File

@ -1373,6 +1373,13 @@ export const MockAuthMethodsPasswordOnly: TypesGen.AuthMethods = {
oidc: { enabled: false, signInText: "", iconUrl: "" }, oidc: { enabled: false, signInText: "", iconUrl: "" },
}; };
export const MockAuthMethodsPasswordTermsOfService: TypesGen.AuthMethods = {
terms_of_service_url: "https://www.youtube.com/watch?v=C2f37Vb2NAE",
password: { enabled: true },
github: { enabled: false },
oidc: { enabled: false, signInText: "", iconUrl: "" },
};
export const MockAuthMethodsExternal: TypesGen.AuthMethods = { export const MockAuthMethodsExternal: TypesGen.AuthMethods = {
password: { enabled: false }, password: { enabled: false },
github: { enabled: true }, github: { enabled: true },