This commit is contained in:
McKayla Washburn 2024-04-24 17:19:39 +00:00
parent 215dd7b152
commit f432099664
21 changed files with 188 additions and 5 deletions

6
coderd/apidoc/docs.go generated
View File

@ -8280,6 +8280,9 @@ const docTemplate = `{
"items": {
"$ref": "#/definitions/codersdk.LinkConfig"
}
},
"terms_of_service": {
"type": "string"
}
}
},
@ -11896,6 +11899,9 @@ const docTemplate = `{
},
"service_banner": {
"$ref": "#/definitions/codersdk.ServiceBannerConfig"
},
"terms_of_service": {
"type": "string"
}
}
},

View File

@ -7349,6 +7349,9 @@
"items": {
"$ref": "#/definitions/codersdk.LinkConfig"
}
},
"terms_of_service": {
"type": "string"
}
}
},
@ -10755,6 +10758,9 @@
},
"service_banner": {
"$ref": "#/definitions/codersdk.ServiceBannerConfig"
},
"terms_of_service": {
"type": "string"
}
}
},

View File

@ -1793,6 +1793,11 @@ func (q *querier) GetTemplatesWithFilter(ctx context.Context, arg database.GetTe
return q.db.GetAuthorizedTemplates(ctx, arg, prep)
}
func (q *querier) GetTermsOfService(ctx context.Context) (string, error) {
// No authz checks
return q.db.GetTermsOfService(ctx)
}
func (q *querier) GetUnexpiredLicenses(ctx context.Context) ([]database.License, error) {
if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil {
return nil, err
@ -3438,6 +3443,13 @@ func (q *querier) UpsertTemplateUsageStats(ctx context.Context) error {
return q.db.UpsertTemplateUsageStats(ctx)
}
func (q *querier) UpsertTermsOfService(ctx context.Context, value string) error {
if err := q.authorizeContext(ctx, rbac.ActionCreate, rbac.ResourceDeploymentValues); err != nil {
return err
}
return q.db.UpsertTermsOfService(ctx, value)
}
func (q *querier) UpsertWorkspaceAgentPortShare(ctx context.Context, arg database.UpsertWorkspaceAgentPortShareParams) (database.WorkspaceAgentPortShare, error) {
workspace, err := q.db.GetWorkspaceByID(ctx, arg.WorkspaceID)
if err != nil {

View File

@ -4213,6 +4213,10 @@ func (q *FakeQuerier) GetTemplatesWithFilter(ctx context.Context, arg database.G
return q.GetAuthorizedTemplates(ctx, arg, nil)
}
func (q *FakeQuerier) GetTermsOfService(ctx context.Context) (string, error) {
panic("not implemented")
}
func (q *FakeQuerier) GetUnexpiredLicenses(_ context.Context) ([]database.License, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
@ -8932,6 +8936,10 @@ TemplateUsageStatsInsertLoop:
return nil
}
func (q *FakeQuerier) UpsertTermsOfService(ctx context.Context, value string) error {
panic("not implemented")
}
func (q *FakeQuerier) UpsertWorkspaceAgentPortShare(_ context.Context, arg database.UpsertWorkspaceAgentPortShareParams) (database.WorkspaceAgentPortShare, error) {
err := validateDatabaseType(arg)
if err != nil {

View File

@ -1038,6 +1038,13 @@ func (m metricsStore) GetTemplatesWithFilter(ctx context.Context, arg database.G
return templates, err
}
func (m metricsStore) GetTermsOfService(ctx context.Context) (string, error) {
start := time.Now()
r0, r1 := m.s.GetTermsOfService(ctx)
m.queryLatencies.WithLabelValues("GetTermsOfService").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) GetUnexpiredLicenses(ctx context.Context) ([]database.License, error) {
start := time.Now()
licenses, err := m.s.GetUnexpiredLicenses(ctx)
@ -2256,6 +2263,13 @@ func (m metricsStore) UpsertTemplateUsageStats(ctx context.Context) error {
return r0
}
func (m metricsStore) UpsertTermsOfService(ctx context.Context, value string) error {
start := time.Now()
r0 := m.s.UpsertTermsOfService(ctx, value)
m.queryLatencies.WithLabelValues("UpsertTermsOfService").Observe(time.Since(start).Seconds())
return r0
}
func (m metricsStore) UpsertWorkspaceAgentPortShare(ctx context.Context, arg database.UpsertWorkspaceAgentPortShareParams) (database.WorkspaceAgentPortShare, error) {
start := time.Now()
r0, r1 := m.s.UpsertWorkspaceAgentPortShare(ctx, arg)

View File

@ -2145,6 +2145,21 @@ func (mr *MockStoreMockRecorder) GetTemplatesWithFilter(arg0, arg1 any) *gomock.
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplatesWithFilter", reflect.TypeOf((*MockStore)(nil).GetTemplatesWithFilter), arg0, arg1)
}
// GetTermsOfService mocks base method.
func (m *MockStore) GetTermsOfService(arg0 context.Context) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetTermsOfService", arg0)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetTermsOfService indicates an expected call of GetTermsOfService.
func (mr *MockStoreMockRecorder) GetTermsOfService(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTermsOfService", reflect.TypeOf((*MockStore)(nil).GetTermsOfService), arg0)
}
// GetUnexpiredLicenses mocks base method.
func (m *MockStore) GetUnexpiredLicenses(arg0 context.Context) ([]database.License, error) {
m.ctrl.T.Helper()
@ -4723,6 +4738,20 @@ func (mr *MockStoreMockRecorder) UpsertTemplateUsageStats(arg0 any) *gomock.Call
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertTemplateUsageStats", reflect.TypeOf((*MockStore)(nil).UpsertTemplateUsageStats), arg0)
}
// UpsertTermsOfService mocks base method.
func (m *MockStore) UpsertTermsOfService(arg0 context.Context, arg1 string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpsertTermsOfService", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// UpsertTermsOfService indicates an expected call of UpsertTermsOfService.
func (mr *MockStoreMockRecorder) UpsertTermsOfService(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertTermsOfService", reflect.TypeOf((*MockStore)(nil).UpsertTermsOfService), arg0, arg1)
}
// UpsertWorkspaceAgentPortShare mocks base method.
func (m *MockStore) UpsertWorkspaceAgentPortShare(arg0 context.Context, arg1 database.UpsertWorkspaceAgentPortShareParams) (database.WorkspaceAgentPortShare, error) {
m.ctrl.T.Helper()

View File

@ -215,6 +215,7 @@ type sqlcQuerier interface {
GetTemplateVersionsCreatedAfter(ctx context.Context, createdAt time.Time) ([]TemplateVersion, error)
GetTemplates(ctx context.Context) ([]Template, error)
GetTemplatesWithFilter(ctx context.Context, arg GetTemplatesWithFilterParams) ([]Template, error)
GetTermsOfService(ctx context.Context) (string, error)
GetUnexpiredLicenses(ctx context.Context) ([]License, error)
// GetUserActivityInsights returns the ranking with top active users.
// The result can be filtered on template_ids, meaning only user data
@ -435,6 +436,7 @@ type sqlcQuerier interface {
// used to store the data, and the minutes are summed for each user and template
// combination. The result is stored in the template_usage_stats table.
UpsertTemplateUsageStats(ctx context.Context) error
UpsertTermsOfService(ctx context.Context, value string) error
UpsertWorkspaceAgentPortShare(ctx context.Context, arg UpsertWorkspaceAgentPortShareParams) (WorkspaceAgentPortShare, error)
}

View File

@ -5637,6 +5637,17 @@ func (q *sqlQuerier) GetServiceBanner(ctx context.Context) (string, error) {
return value, err
}
const getTermsOfService = `-- name: GetTermsOfService :one
SELECT value FROM site_configs WHERE key = 'terms_of_service'
`
func (q *sqlQuerier) GetTermsOfService(ctx context.Context) (string, error) {
row := q.db.QueryRowContext(ctx, getTermsOfService)
var value string
err := row.Scan(&value)
return value, err
}
const insertDERPMeshKey = `-- name: InsertDERPMeshKey :exec
INSERT INTO site_configs (key, value) VALUES ('derp_mesh_key', $1)
`
@ -5748,6 +5759,16 @@ func (q *sqlQuerier) UpsertServiceBanner(ctx context.Context, value string) erro
return err
}
const upsertTermsOfService = `-- name: UpsertTermsOfService :exec
INSERT INTO site_configs (key, value) VALUES ('terms_of_service', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'terms_of_service'
`
func (q *sqlQuerier) UpsertTermsOfService(ctx context.Context, value string) error {
_, err := q.db.ExecContext(ctx, upsertTermsOfService, value)
return err
}
const cleanTailnetCoordinators = `-- name: CleanTailnetCoordinators :exec
DELETE
FROM tailnet_coordinators

View File

@ -57,6 +57,13 @@ ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'application
-- name: GetApplicationName :one
SELECT value FROM site_configs WHERE key = 'application_name';
-- name: UpsertTermsOfService :exec
INSERT INTO site_configs (key, value) VALUES ('terms_of_service', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'terms_of_service';
-- name: GetTermsOfService :one
SELECT value FROM site_configs WHERE key = 'terms_of_service';
-- name: GetAppSecurityKey :one
SELECT value FROM site_configs WHERE key = 'app_signing_key';

View File

@ -2078,6 +2078,7 @@ type AppearanceConfig struct {
ApplicationName string `json:"application_name"`
LogoURL string `json:"logo_url"`
ServiceBanner ServiceBannerConfig `json:"service_banner"`
TermsOfService string `json:"terms_of_service"`
SupportLinks []LinkConfig `json:"support_links,omitempty"`
}
@ -2085,6 +2086,7 @@ type UpdateAppearanceConfig struct {
ApplicationName string `json:"application_name"`
LogoURL string `json:"logo_url"`
ServiceBanner ServiceBannerConfig `json:"service_banner"`
TermsOfService string `json:"terms_of_service"`
}
type ServiceBannerConfig struct {

View File

@ -32,7 +32,8 @@ curl -X GET http://coder-server:8080/api/v2/appearance \
"name": "string",
"target": "string"
}
]
],
"terms_of_service": "string"
}
```
@ -68,7 +69,8 @@ curl -X PUT http://coder-server:8080/api/v2/appearance \
"background_color": "string",
"enabled": true,
"message": "string"
}
},
"terms_of_service": "string"
}
```
@ -90,7 +92,8 @@ curl -X PUT http://coder-server:8080/api/v2/appearance \
"background_color": "string",
"enabled": true,
"message": "string"
}
},
"terms_of_service": "string"
}
```

8
docs/api/schemas.md generated
View File

@ -762,7 +762,8 @@
"name": "string",
"target": "string"
}
]
],
"terms_of_service": "string"
}
```
@ -774,6 +775,7 @@
| `logo_url` | string | false | | |
| `service_banner` | [codersdk.ServiceBannerConfig](#codersdkservicebannerconfig) | false | | |
| `support_links` | array of [codersdk.LinkConfig](#codersdklinkconfig) | false | | |
| `terms_of_service` | string | false | | |
## codersdk.ArchiveTemplateVersionsRequest
@ -5172,7 +5174,8 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"background_color": "string",
"enabled": true,
"message": "string"
}
},
"terms_of_service": "string"
}
```
@ -5183,6 +5186,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
| `application_name` | string | false | | |
| `logo_url` | string | false | | |
| `service_banner` | [codersdk.ServiceBannerConfig](#codersdkservicebannerconfig) | false | | |
| `terms_of_service` | string | false | | |
## codersdk.UpdateCheckResponse

View File

@ -56,6 +56,7 @@ func (f *appearanceFetcher) Fetch(ctx context.Context) (codersdk.AppearanceConfi
var applicationName string
var logoURL string
var serviceBannerJSON string
var termsOfService string
eg.Go(func() (err error) {
applicationName, err = f.database.GetApplicationName(ctx)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
@ -77,6 +78,13 @@ func (f *appearanceFetcher) Fetch(ctx context.Context) (codersdk.AppearanceConfi
}
return nil
})
eg.Go(func() (err error) {
termsOfService, err = f.database.GetTermsOfService(ctx)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return xerrors.Errorf("get terms of service: %w", err)
}
return nil
})
err := eg.Wait()
if err != nil {
return codersdk.AppearanceConfig{}, err
@ -85,6 +93,7 @@ func (f *appearanceFetcher) Fetch(ctx context.Context) (codersdk.AppearanceConfi
cfg := codersdk.AppearanceConfig{
ApplicationName: applicationName,
LogoURL: logoURL,
TermsOfService: termsOfService,
}
if serviceBannerJSON != "" {
err = json.Unmarshal([]byte(serviceBannerJSON), &cfg.ServiceBanner)
@ -176,6 +185,15 @@ func (api *API) putAppearance(rw http.ResponseWriter, r *http.Request) {
return
}
err = api.Database.UpsertTermsOfService(ctx, appearance.TermsOfService)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Unable to set terms of service",
Detail: err.Error(),
})
return
}
err = api.Database.UpsertLogoURL(ctx, appearance.LogoURL)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{

View File

@ -1282,6 +1282,8 @@ export const getAppearance = async (): Promise<TypesGen.AppearanceConfig> => {
const response = await axios.get(`/api/v2/appearance`);
return response.data || {};
} catch (ex) {
// This endpoint is only available on enterprise binaries. A 404 is expected
// from AGPL builds, and should be be treated as a "successful" response.
if (axios.isAxiosError(ex) && ex.response?.status === 404) {
return {
application_name: "",
@ -1289,6 +1291,7 @@ export const getAppearance = async (): Promise<TypesGen.AppearanceConfig> => {
service_banner: {
enabled: false,
},
terms_of_service: "",
};
}
throw ex;

View File

@ -49,6 +49,7 @@ export interface AppearanceConfig {
readonly application_name: string;
readonly logo_url: string;
readonly service_banner: ServiceBannerConfig;
readonly terms_of_service: string;
readonly support_links?: readonly LinkConfig[];
}
@ -1279,6 +1280,7 @@ export interface UpdateAppearanceConfig {
readonly application_name: string;
readonly logo_url: string;
readonly service_banner: ServiceBannerConfig;
readonly terms_of_service: string;
}
// From codersdk/updatecheck.go

View File

@ -22,6 +22,7 @@ const AppearanceSettingsPage: FC = () => {
newConfig: Partial<UpdateAppearanceConfig>,
preview: boolean,
) => {
console.log(newConfig);
const newAppearance = { ...appearance.config, ...newConfig };
if (preview) {
appearance.setPreview(newAppearance);

View File

@ -13,6 +13,7 @@ const meta: Meta<typeof AppearanceSettingsPageView> = {
message: "hello world",
background_color: "white",
},
terms_of_service: "",
},
isEntitled: false,
},

View File

@ -80,6 +80,16 @@ export const AppearanceSettingsPageView: FC<
serviceBannerForm.values.background_color,
);
const termsOfServiceForm = useFormik<{
terms_of_service: string;
}>({
initialValues: {
terms_of_service: appearance.terms_of_service,
},
onSubmit: (values) => onSaveAppearance(values, false),
});
const termsOfServiceFieldHelpers = getFormHelpers(termsOfServiceForm);
return (
<>
<Header
@ -276,6 +286,30 @@ export const AppearanceSettingsPageView: FC<
</Stack>
)}
</Fieldset>
<Fieldset
title="Terms of Service"
subtitle="Add a custom Terms of Service that must be accepted before using Coder."
validation={!isEntitled ? "This is an Enterprise only feature." : ""}
onSubmit={termsOfServiceForm.handleSubmit}
button={!isEntitled && <Button disabled>Submit</Button>}
>
<TextField
{...termsOfServiceFieldHelpers("terms_of_service", {
helperText: "Markdown is supported.",
})}
defaultValue={appearance.terms_of_service}
fullWidth
multiline
label="Terms of Service"
placeholder="Leave empty to disable."
disabled={!isEntitled}
inputProps={{
"aria-label": "Terms of Service. Leave empty to disable.",
style: { height: 100, resize: "vertical" },
}}
/>
</Fieldset>
</>
);
};

View File

@ -8,6 +8,7 @@ import { useAuthContext } from "contexts/auth/AuthProvider";
import { getApplicationName } from "utils/appearance";
import { retrieveRedirect } from "utils/redirect";
import { LoginPageView } from "./LoginPageView";
import { appearance } from "api/queries/appearance";
export const LoginPage: FC = () => {
const location = useLocation();
@ -24,6 +25,7 @@ export const LoginPage: FC = () => {
const applicationName = getApplicationName();
const navigate = useNavigate();
const buildInfoQuery = useQuery(buildInfo());
const siteConfigQuery = useQuery(appearance());
if (isSignedIn) {
// If the redirect is going to a workspace application, and we
@ -68,6 +70,7 @@ export const LoginPage: FC = () => {
error={signInError}
isLoading={isLoading || authMethodsQuery.isLoading}
buildInfo={buildInfoQuery.data}
termsOfService={siteConfigQuery.data?.terms_of_service}
isSigningIn={isSigningIn}
onSignIn={async ({ email, password }) => {
await signIn(email, password);

View File

@ -13,6 +13,7 @@ export interface LoginPageViewProps {
error: unknown;
isLoading: boolean;
buildInfo?: BuildInfoResponse;
termsOfService?: string;
isSigningIn: boolean;
onSignIn: (credentials: { email: string; password: string }) => void;
}
@ -22,6 +23,7 @@ export const LoginPageView: FC<LoginPageViewProps> = ({
error,
isLoading,
buildInfo,
termsOfService,
isSigningIn,
onSignIn,
}) => {
@ -49,6 +51,10 @@ export const LoginPageView: FC<LoginPageViewProps> = ({
<CoderIcon fill="white" opacity={1} css={styles.icon} />
);
if (termsOfService) {
return <>{termsOfService}</>;
}
return (
<div css={styles.root}>
<div css={styles.container}>

View File

@ -2347,6 +2347,7 @@ export const MockAppearanceConfig: TypesGen.AppearanceConfig = {
service_banner: {
enabled: false,
},
terms_of_service: "",
};
export const MockWorkspaceBuildParameter1: TypesGen.WorkspaceBuildParameter = {