feat: enable enterprise users to specify a custom logo (#5566)

* feat: enable enterprise users to specify a custom logo

This adds a field in deployment settings that allows users to specify
the URL to a custom logo that will display in the dashboard.

This also groups service banner into a new appearance settings page.
It adds a Fieldset component to allow for modular fields moving forward.

* Fix tests
This commit is contained in:
Kyle Carberry 2023-01-04 15:31:45 -06:00 committed by GitHub
parent 175be621cf
commit 0dba2defd1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 824 additions and 597 deletions

View File

@ -123,6 +123,7 @@ type data struct {
derpMeshKey string
lastUpdateCheck []byte
serviceBanner []byte
logoURL string
lastLicenseID int32
}
@ -3356,6 +3357,25 @@ func (q *fakeQuerier) GetServiceBanner(_ context.Context) (string, error) {
return string(q.serviceBanner), nil
}
func (q *fakeQuerier) InsertOrUpdateLogoURL(_ context.Context, data string) error {
q.mutex.RLock()
defer q.mutex.RUnlock()
q.logoURL = data
return nil
}
func (q *fakeQuerier) GetLogoURL(_ context.Context) (string, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
if q.logoURL == "" {
return "", sql.ErrNoRows
}
return q.logoURL, nil
}
func (q *fakeQuerier) InsertLicense(
_ context.Context, arg database.InsertLicenseParams,
) (database.License, error) {

View File

@ -57,6 +57,7 @@ type sqlcQuerier interface {
GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceBuild, error)
GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error)
GetLicenses(ctx context.Context) ([]License, error)
GetLogoURL(ctx context.Context) (string, error)
GetOrganizationByID(ctx context.Context, id uuid.UUID) (Organization, error)
GetOrganizationByName(ctx context.Context, name string) (Organization, error)
GetOrganizationIDsByMemberIDs(ctx context.Context, ids []uuid.UUID) ([]GetOrganizationIDsByMemberIDsRow, error)
@ -146,6 +147,7 @@ type sqlcQuerier interface {
InsertGroupMember(ctx context.Context, arg InsertGroupMemberParams) error
InsertLicense(ctx context.Context, arg InsertLicenseParams) (License, error)
InsertOrUpdateLastUpdateCheck(ctx context.Context, value string) error
InsertOrUpdateLogoURL(ctx context.Context, value string) error
InsertOrUpdateServiceBanner(ctx context.Context, value string) error
InsertOrganization(ctx context.Context, arg InsertOrganizationParams) (Organization, error)
InsertOrganizationMember(ctx context.Context, arg InsertOrganizationMemberParams) (OrganizationMember, error)

View File

@ -2980,6 +2980,17 @@ func (q *sqlQuerier) GetLastUpdateCheck(ctx context.Context) (string, error) {
return value, err
}
const getLogoURL = `-- name: GetLogoURL :one
SELECT value FROM site_configs WHERE key = 'logo_url'
`
func (q *sqlQuerier) GetLogoURL(ctx context.Context) (string, error) {
row := q.db.QueryRowContext(ctx, getLogoURL)
var value string
err := row.Scan(&value)
return value, err
}
const getServiceBanner = `-- name: GetServiceBanner :one
SELECT value FROM site_configs WHERE key = 'service_banner'
`
@ -3019,6 +3030,16 @@ func (q *sqlQuerier) InsertOrUpdateLastUpdateCheck(ctx context.Context, value st
return err
}
const insertOrUpdateLogoURL = `-- name: InsertOrUpdateLogoURL :exec
INSERT INTO site_configs (key, value) VALUES ('logo_url', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'logo_url'
`
func (q *sqlQuerier) InsertOrUpdateLogoURL(ctx context.Context, value string) error {
_, err := q.db.ExecContext(ctx, insertOrUpdateLogoURL, value)
return err
}
const insertOrUpdateServiceBanner = `-- name: InsertOrUpdateServiceBanner :exec
INSERT INTO site_configs (key, value) VALUES ('service_banner', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'service_banner'

View File

@ -23,3 +23,10 @@ ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'service_ban
-- name: GetServiceBanner :one
SELECT value FROM site_configs WHERE key = 'service_banner';
-- name: InsertOrUpdateLogoURL :exec
INSERT INTO site_configs (key, value) VALUES ('logo_url', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'logo_url';
-- name: GetLogoURL :one
SELECT value FROM site_configs WHERE key = 'logo_url';

43
codersdk/appearance.go Normal file
View File

@ -0,0 +1,43 @@
package codersdk
import (
"context"
"encoding/json"
"net/http"
)
type AppearanceConfig struct {
LogoURL string `json:"logo_url"`
ServiceBanner ServiceBannerConfig `json:"service_banner"`
}
type ServiceBannerConfig struct {
Enabled bool `json:"enabled"`
Message string `json:"message,omitempty"`
BackgroundColor string `json:"background_color,omitempty"`
}
func (c *Client) Appearance(ctx context.Context) (AppearanceConfig, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/appearance", nil)
if err != nil {
return AppearanceConfig{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return AppearanceConfig{}, readBodyAsError(res)
}
var cfg AppearanceConfig
return cfg, json.NewDecoder(res.Body).Decode(&cfg)
}
func (c *Client) UpdateAppearance(ctx context.Context, appearance AppearanceConfig) error {
res, err := c.Request(ctx, http.MethodPut, "/api/v2/appearance", appearance)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return readBodyAsError(res)
}
return nil
}

23
codersdk/branding.go Normal file
View File

@ -0,0 +1,23 @@
package codersdk
import (
"context"
"net/http"
)
type UpdateBrandingRequest struct {
LogoURL string `json:"logo_url"`
}
// UpdateBranding applies customization settings available to Enterprise customers.
func (c *Client) UpdateBranding(ctx context.Context, req UpdateBrandingRequest) error {
res, err := c.Request(ctx, http.MethodPut, "/api/v2/branding", req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return readBodyAsError(res)
}
return nil
}

View File

@ -23,7 +23,7 @@ const (
FeatureHighAvailability = "high_availability"
FeatureMultipleGitAuth = "multiple_git_auth"
FeatureExternalProvisionerDaemons = "external_provisioner_daemons"
FeatureServiceBanners = "service_banners"
FeatureAppearance = "appearance"
)
var FeatureNames = []string{
@ -35,7 +35,7 @@ var FeatureNames = []string{
FeatureHighAvailability,
FeatureMultipleGitAuth,
FeatureExternalProvisionerDaemons,
FeatureServiceBanners,
FeatureAppearance,
}
type Feature struct {

View File

@ -1,38 +0,0 @@
package codersdk
import (
"context"
"encoding/json"
"net/http"
)
type ServiceBanner struct {
Enabled bool `json:"enabled"`
Message string `json:"message,omitempty"`
BackgroundColor string `json:"background_color,omitempty"`
}
func (c *Client) ServiceBanner(ctx context.Context) (*ServiceBanner, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/service-banner", nil)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
}
var b ServiceBanner
return &b, json.NewDecoder(res.Body).Decode(&b)
}
func (c *Client) SetServiceBanner(ctx context.Context, s *ServiceBanner) error {
res, err := c.Request(ctx, http.MethodPut, "/api/v2/service-banner", s)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return readBodyAsError(res)
}
return nil
}

View File

@ -0,0 +1,126 @@
package coderd
import (
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/codersdk"
)
func (api *API) appearance(rw http.ResponseWriter, r *http.Request) {
api.entitlementsMu.RLock()
isEntitled := api.entitlements.Features[codersdk.FeatureAppearance].Entitlement == codersdk.EntitlementEntitled
api.entitlementsMu.RUnlock()
ctx := r.Context()
if !isEntitled {
httpapi.Write(ctx, rw, http.StatusOK, codersdk.AppearanceConfig{})
return
}
logoURL, err := api.Database.GetLogoURL(ctx)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to fetch logo URL.",
Detail: err.Error(),
})
return
}
serviceBannerJSON, err := api.Database.GetServiceBanner(r.Context())
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to fetch service banner.",
Detail: err.Error(),
})
return
}
cfg := codersdk.AppearanceConfig{
LogoURL: logoURL,
}
if serviceBannerJSON != "" {
err = json.Unmarshal([]byte(serviceBannerJSON), &cfg.ServiceBanner)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: fmt.Sprintf(
"unmarshal json: %+v, raw: %s", err, serviceBannerJSON,
),
})
return
}
}
httpapi.Write(r.Context(), rw, http.StatusOK, cfg)
}
func validateHexColor(color string) error {
if len(color) != 7 {
return xerrors.New("expected 7 characters")
}
if color[0] != '#' {
return xerrors.New("no # prefix")
}
_, err := hex.DecodeString(color[1:])
return err
}
func (api *API) putAppearance(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, rbac.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Insufficient permissions to update appearance",
})
return
}
var appearance codersdk.AppearanceConfig
if !httpapi.Read(ctx, rw, r, &appearance) {
return
}
if appearance.ServiceBanner.Enabled {
if err := validateHexColor(appearance.ServiceBanner.BackgroundColor); err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("parse color: %+v", err),
})
return
}
}
serviceBannerJSON, err := json.Marshal(appearance.ServiceBanner)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("marshal banner: %+v", err),
})
return
}
err = api.Database.InsertOrUpdateServiceBanner(ctx, string(serviceBannerJSON))
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: fmt.Sprintf("database error: %+v", err),
})
return
}
err = api.Database.InsertOrUpdateLogoURL(ctx, appearance.LogoURL)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: fmt.Sprintf("database error: %+v", err),
})
return
}
httpapi.Write(r.Context(), rw, http.StatusOK, appearance)
}

View File

@ -25,24 +25,24 @@ func TestServiceBanners(t *testing.T) {
adminUser := coderdtest.CreateFirstUser(t, adminClient)
// Even without a license, the banner should return as disabled.
sb, err := adminClient.ServiceBanner(ctx)
sb, err := adminClient.Appearance(ctx)
require.NoError(t, err)
require.False(t, sb.Enabled)
require.False(t, sb.ServiceBanner.Enabled)
coderdenttest.AddLicense(t, adminClient, coderdenttest.LicenseOptions{
ServiceBanners: true,
})
// Default state
sb, err = adminClient.ServiceBanner(ctx)
sb, err = adminClient.Appearance(ctx)
require.NoError(t, err)
require.False(t, sb.Enabled)
require.False(t, sb.ServiceBanner.Enabled)
basicUserClient := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID)
// Regular user should be unable to set the banner
sb.Enabled = true
err = basicUserClient.SetServiceBanner(ctx, sb)
sb.ServiceBanner.Enabled = true
err = basicUserClient.UpdateAppearance(ctx, sb)
require.Error(t, err)
var sdkError *codersdk.Error
require.True(t, errors.As(err, &sdkError))
@ -50,17 +50,17 @@ func TestServiceBanners(t *testing.T) {
// But an admin can
wantBanner := sb
wantBanner.Enabled = true
wantBanner.Message = "Hey"
wantBanner.BackgroundColor = "#00FF00"
err = adminClient.SetServiceBanner(ctx, wantBanner)
wantBanner.ServiceBanner.Enabled = true
wantBanner.ServiceBanner.Message = "Hey"
wantBanner.ServiceBanner.BackgroundColor = "#00FF00"
err = adminClient.UpdateAppearance(ctx, wantBanner)
require.NoError(t, err)
gotBanner, err := adminClient.ServiceBanner(ctx)
gotBanner, err := adminClient.Appearance(ctx)
require.NoError(t, err)
require.Equal(t, wantBanner, gotBanner)
// But even an admin can't give a bad color
wantBanner.BackgroundColor = "#bad color"
err = adminClient.SetServiceBanner(ctx, wantBanner)
wantBanner.ServiceBanner.BackgroundColor = "#bad color"
err = adminClient.UpdateAppearance(ctx, wantBanner)
require.Error(t, err)
}

View File

@ -127,12 +127,12 @@ func New(ctx context.Context, options *Options) (*API, error) {
r.Get("/", api.workspaceQuota)
})
})
r.Route("/service-banner", func(r chi.Router) {
r.Route("/appearance", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
)
r.Get("/", api.serviceBanner)
r.Put("/", api.putServiceBanner)
r.Get("/", api.appearance)
r.Put("/", api.putAppearance)
})
})

View File

@ -192,7 +192,7 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
TemplateRBAC: rbacEnabled,
MultipleGitAuth: multipleGitAuth,
ExternalProvisionerDaemons: externalProvisionerDaemons,
ServiceBanners: serviceBanners,
Appearance: serviceBanners,
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c)

View File

@ -49,7 +49,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
skipRoutes, assertRoute := coderdtest.AGPLRoutes(a)
skipRoutes["GET:/api/v2/organizations/{organization}/provisionerdaemons/serve"] = "This route checks for RBAC dependent on input parameters!"
skipRoutes["GET:/api/v2/service-banner/"] = "This route is available to all users"
skipRoutes["GET:/api/v2/appearance/"] = "This route is available to all users"
assertRoute["GET:/api/v2/entitlements"] = coderdtest.RouteCheck{
NoAuthorize: true,

View File

@ -123,8 +123,8 @@ func Entitlements(
Enabled: true,
}
}
if claims.Features.ServiceBanners > 0 {
entitlements.Features[codersdk.FeatureServiceBanners] = codersdk.Feature{
if claims.Features.Appearance > 0 {
entitlements.Features[codersdk.FeatureAppearance] = codersdk.Feature{
Entitlement: entitlement,
Enabled: true,
}
@ -258,7 +258,7 @@ type Features struct {
HighAvailability int64 `json:"high_availability"`
MultipleGitAuth int64 `json:"multiple_git_auth"`
ExternalProvisionerDaemons int64 `json:"external_provisioner_daemons"`
ServiceBanners int64 `json:"service_banners"`
Appearance int64 `json:"appearance"`
}
type Claims struct {

View File

@ -27,7 +27,7 @@ func TestEntitlements(t *testing.T) {
codersdk.FeatureTemplateRBAC: true,
codersdk.FeatureMultipleGitAuth: true,
codersdk.FeatureExternalProvisionerDaemons: true,
codersdk.FeatureServiceBanners: true,
codersdk.FeatureAppearance: true,
}
t.Run("Defaults", func(t *testing.T) {

View File

@ -109,7 +109,7 @@ func TestGetLicense(t *testing.T) {
codersdk.FeatureTemplateRBAC: json.Number("1"),
codersdk.FeatureMultipleGitAuth: json.Number("0"),
codersdk.FeatureExternalProvisionerDaemons: json.Number("0"),
codersdk.FeatureServiceBanners: json.Number("0"),
codersdk.FeatureAppearance: json.Number("0"),
}, licenses[0].Claims["features"])
assert.Equal(t, int32(2), licenses[1].ID)
assert.Equal(t, "testing2", licenses[1].Claims["account_id"])
@ -123,7 +123,7 @@ func TestGetLicense(t *testing.T) {
codersdk.FeatureTemplateRBAC: json.Number("0"),
codersdk.FeatureMultipleGitAuth: json.Number("0"),
codersdk.FeatureExternalProvisionerDaemons: json.Number("0"),
codersdk.FeatureServiceBanners: json.Number("0"),
codersdk.FeatureAppearance: json.Number("0"),
}, licenses[1].Claims["features"])
})
}

View File

@ -1,109 +0,0 @@
package coderd
import (
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/codersdk"
)
func (api *API) serviceBanner(rw http.ResponseWriter, r *http.Request) {
api.entitlementsMu.RLock()
isEntitled := api.entitlements.Features[codersdk.FeatureServiceBanners].Entitlement == codersdk.EntitlementEntitled
api.entitlementsMu.RUnlock()
ctx := r.Context()
if !isEntitled {
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ServiceBanner{
Enabled: false,
})
return
}
serviceBannerJSON, err := api.Database.GetServiceBanner(r.Context())
if errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ServiceBanner{
Enabled: false,
})
return
} else if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: fmt.Sprintf("database error: %+v", err),
})
return
}
var serviceBanner codersdk.ServiceBanner
err = json.Unmarshal([]byte(serviceBannerJSON), &serviceBanner)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: fmt.Sprintf(
"unmarshal json: %+v, raw: %s", err, serviceBannerJSON,
),
})
return
}
httpapi.Write(r.Context(), rw, http.StatusOK, serviceBanner)
}
func validateHexColor(color string) error {
if len(color) != 7 {
return xerrors.New("expected 7 characters")
}
if color[0] != '#' {
return xerrors.New("no # prefix")
}
_, err := hex.DecodeString(color[1:])
return err
}
func (api *API) putServiceBanner(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, rbac.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Insufficient permissions to update service banner",
})
return
}
var serviceBanner codersdk.ServiceBanner
if !httpapi.Read(ctx, rw, r, &serviceBanner) {
return
}
if err := validateHexColor(serviceBanner.BackgroundColor); err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("parse color: %+v", err),
})
return
}
serviceBannerJSON, err := json.Marshal(serviceBanner)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("marshal banner: %+v", err),
})
return
}
err = api.Database.InsertOrUpdateServiceBanner(ctx, string(serviceBannerJSON))
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: fmt.Sprintf("database error: %+v", err),
})
return
}
httpapi.Write(r.Context(), rw, http.StatusOK, serviceBanner)
}

View File

@ -76,8 +76,8 @@ const GeneralSettingsPage = lazy(
const SecuritySettingsPage = lazy(
() => import("./pages/DeploySettingsPage/SecuritySettingsPage"),
)
const ServiceBannerSettingsPage = lazy(
() => import("./pages/DeploySettingsPage/ServiceBannerSettingsPage"),
const AppearanceSettingsPage = lazy(
() => import("./pages/DeploySettingsPage/AppearanceSettingsPage"),
)
const UserAuthSettingsPage = lazy(
() => import("./pages/DeploySettingsPage/UserAuthSettingsPage"),
@ -345,14 +345,14 @@ export const AppRouter: FC = () => {
}
/>
<Route
path="service-banner"
path="appearance"
element={
<AuthAndFrame>
<RequirePermission
isFeatureVisible={Boolean(permissions?.viewDeploymentConfig)}
>
<DeploySettingsLayout>
<ServiceBannerSettingsPage />
<AppearanceSettingsPage />
</DeploySettingsLayout>
</RequirePermission>
</AuthAndFrame>

View File

@ -723,15 +723,15 @@ export const getFile = async (fileId: string): Promise<ArrayBuffer> => {
return response.data
}
export const getServiceBanner = async (): Promise<TypesGen.ServiceBanner> => {
const response = await axios.get(`/api/v2/service-banner`)
export const getAppearance = async (): Promise<TypesGen.AppearanceConfig> => {
const response = await axios.get(`/api/v2/appearance`)
return response.data
}
export const setServiceBanner = async (
b: TypesGen.ServiceBanner,
): Promise<TypesGen.ServiceBanner> => {
const response = await axios.put(`/api/v2/service-banner`, b)
export const updateAppearance = async (
b: TypesGen.AppearanceConfig,
): Promise<TypesGen.AppearanceConfig> => {
const response = await axios.put(`/api/v2/appearance`, b)
return response.data
}

View File

@ -23,5 +23,5 @@ export enum FeatureNames {
SCIM = "scim",
TemplateRBAC = "template_rbac",
HighAvailability = "high_availability",
ServiceBanners = "service_banners",
Appearance = "appearance",
}

View File

@ -31,6 +31,12 @@ export interface AgentStatsReportResponse {
readonly tx_bytes: number
}
// From codersdk/appearance.go
export interface AppearanceConfig {
readonly logo_url: string
readonly service_banner: ServiceBannerConfig
}
// From codersdk/roles.go
export interface AssignableRoles extends Role {
readonly assignable: boolean
@ -611,8 +617,8 @@ export interface ServerSentEvent {
readonly data: any
}
// From codersdk/servicebanner.go
export interface ServiceBanner {
// From codersdk/appearance.go
export interface ServiceBannerConfig {
readonly enabled: boolean
readonly message?: string
readonly background_color?: string
@ -739,6 +745,11 @@ export interface UpdateActiveTemplateVersion {
readonly id: string
}
// From codersdk/branding.go
export interface UpdateBrandingRequest {
readonly logo_url: string
}
// From codersdk/updatecheck.go
export interface UpdateCheckResponse {
readonly current: boolean

View File

@ -0,0 +1,66 @@
import { makeStyles } from "@material-ui/core/styles"
import React from "react"
import Button from "@material-ui/core/Button"
export const Fieldset: React.FC<{
children: React.ReactNode
title: string | JSX.Element
validation?: string | JSX.Element | false
button?: JSX.Element | false
onSubmit: React.FormEventHandler<HTMLFormElement>
isSubmitting?: boolean
}> = ({ title, children, validation, button, onSubmit, isSubmitting }) => {
const styles = useStyles()
return (
<form className={styles.fieldset} onSubmit={onSubmit}>
<header className={styles.header}>
<div className={styles.title}>{title}</div>
<div className={styles.body}>{children}</div>
</header>
<footer className={styles.footer}>
<div className={styles.validation}>{validation}</div>
{button || (
<Button type="submit" disabled={isSubmitting}>
Submit
</Button>
)}
</footer>
</form>
)
}
const useStyles = makeStyles((theme) => ({
fieldset: {
borderRadius: theme.spacing(1),
border: `1px solid ${theme.palette.divider}`,
background: theme.palette.background.paper,
marginTop: theme.spacing(4),
},
title: {
...theme.typography.h5,
fontWeight: "bold",
},
body: {
...theme.typography.body2,
paddingTop: theme.spacing(2),
"& p": {
marginTop: 0,
marginBottom: theme.spacing(2),
},
},
validation: {
color: theme.palette.text.secondary,
},
header: {
padding: theme.spacing(3),
},
footer: {
background: theme.palette.background.paperLight,
padding: `${theme.spacing(2)}px ${theme.spacing(3)}px`,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
},
}))

View File

@ -1,21 +1,14 @@
import { makeStyles } from "@material-ui/core/styles"
import Brush from "@material-ui/icons/Brush"
import LaunchOutlined from "@material-ui/icons/LaunchOutlined"
import LockRounded from "@material-ui/icons/LockRounded"
import Globe from "@material-ui/icons/Public"
import VpnKeyOutlined from "@material-ui/icons/VpnKeyOutlined"
import Info from "@material-ui/icons/Info"
import { useSelector } from "@xstate/react"
import { GitIcon } from "components/Icons/GitIcon"
import { Stack } from "components/Stack/Stack"
import React, {
ElementType,
PropsWithChildren,
ReactNode,
useContext,
} from "react"
import React, { ElementType, PropsWithChildren, ReactNode } from "react"
import { NavLink } from "react-router-dom"
import { combineClasses } from "util/combineClasses"
import { XServiceContext } from "../../xServices/StateContext"
const SidebarNavItem: React.FC<
PropsWithChildren<{ href: string; icon: ReactNode }>
@ -48,11 +41,6 @@ const SidebarNavItemIcon: React.FC<{ icon: ElementType }> = ({
export const Sidebar: React.FC = () => {
const styles = useStyles()
const xServices = useContext(XServiceContext)
const experimental = useSelector(
xServices.entitlementsXService,
(state) => state.context.entitlements.experimental,
)
return (
<nav className={styles.sidebar}>
@ -62,20 +50,24 @@ export const Sidebar: React.FC = () => {
>
General
</SidebarNavItem>
<SidebarNavItem
href="../appearance"
icon={<SidebarNavItemIcon icon={Brush} />}
>
Appearance
</SidebarNavItem>
<SidebarNavItem
href="../userauth"
icon={<SidebarNavItemIcon icon={VpnKeyOutlined} />}
>
User Authentication
</SidebarNavItem>
{experimental && (
<SidebarNavItem
href="../gitauth"
icon={<SidebarNavItemIcon icon={GitIcon} />}
>
Git Authentication
</SidebarNavItem>
)}
<SidebarNavItem
href="../gitauth"
icon={<SidebarNavItemIcon icon={GitIcon} />}
>
Git Authentication
</SidebarNavItem>
<SidebarNavItem
href="../network"
icon={<SidebarNavItemIcon icon={Globe} />}
@ -88,12 +80,6 @@ export const Sidebar: React.FC = () => {
>
Security
</SidebarNavItem>
<SidebarNavItem
href="../service-banner"
icon={<SidebarNavItemIcon icon={Info} />}
>
Service Banner
</SidebarNavItem>
</nav>
)
}

View File

@ -7,6 +7,7 @@ import { NavbarView } from "../NavbarView/NavbarView"
export const Navbar: React.FC = () => {
const xServices = useContext(XServiceContext)
const [appearanceState] = useActor(xServices.appearanceXService)
const [authState, authSend] = useActor(xServices.authXService)
const [buildInfoState] = useActor(xServices.buildInfoXService)
const { me, permissions } = authState.context
@ -24,6 +25,7 @@ export const Navbar: React.FC = () => {
return (
<NavbarView
user={me}
logo_url={appearanceState.context.appearance.logo_url}
buildInfo={buildInfoState.context.buildInfo}
onSignOut={onSignOut}
canViewAuditLog={canViewAuditLog}

View File

@ -14,6 +14,7 @@ import { Logo } from "../Icons/Logo"
import { UserDropdown } from "../UserDropdown/UsersDropdown"
export interface NavbarViewProps {
logo_url?: string
user?: TypesGen.User
buildInfo?: TypesGen.BuildInfoResponse
onSignOut: () => void
@ -84,6 +85,7 @@ const NavItems: React.FC<
}
export const NavbarView: React.FC<React.PropsWithChildren<NavbarViewProps>> = ({
user,
logo_url,
buildInfo,
onSignOut,
canViewAuditLog,
@ -112,7 +114,11 @@ export const NavbarView: React.FC<React.PropsWithChildren<NavbarViewProps>> = ({
>
<div className={styles.drawer}>
<div className={styles.drawerHeader}>
<Logo fill="white" opacity={1} width={125} />
{logo_url ? (
<img src={logo_url} alt="Custom Logo" />
) : (
<Logo fill="white" opacity={1} width={125} />
)}
</div>
<NavItems
canViewAuditLog={canViewAuditLog}
@ -122,7 +128,11 @@ export const NavbarView: React.FC<React.PropsWithChildren<NavbarViewProps>> = ({
</Drawer>
<NavLink className={styles.logo} to="/workspaces">
<Logo fill="white" opacity={1} width={125} />
{logo_url ? (
<img src={logo_url} alt="Custom Logo" />
) : (
<Logo fill="white" opacity={1} width={125} />
)}
</NavLink>
<NavItems
@ -199,8 +209,10 @@ const useStyles = makeStyles((theme) => ({
display: "flex",
height: navHeight,
paddingRight: theme.spacing(4),
"& svg": {
// svg is for the Coder logo, img is for custom images
"& svg, & img": {
width: 109,
maxHeight: `calc(${navHeight}px)`,
},
},
title: {

View File

@ -1,20 +1,14 @@
import { useActor } from "@xstate/react"
import { useContext, useEffect } from "react"
import { useContext } from "react"
import { XServiceContext } from "xServices/StateContext"
import { ServiceBannerView } from "./ServiceBannerView"
export const ServiceBanner: React.FC = () => {
const xServices = useContext(XServiceContext)
const [serviceBannerState, serviceBannerSend] = useActor(
xServices.serviceBannerXService,
)
const [appearanceState] = useActor(xServices.appearanceXService)
const { message, background_color, enabled } =
serviceBannerState.context.serviceBanner
useEffect(() => {
serviceBannerSend("GET_BANNER")
}, [serviceBannerSend])
appearanceState.context.appearance.service_banner
if (!enabled) {
return null
@ -25,7 +19,7 @@ export const ServiceBanner: React.FC = () => {
<ServiceBannerView
message={message}
backgroundColor={background_color}
preview={serviceBannerState.context.preview}
preview={appearanceState.context.preview}
/>
)
} else {

View File

@ -13,7 +13,7 @@ import templateVersionPage from "./templateVersionPage.json"
import loginPage from "./loginPage.json"
import workspaceChangeVersionPage from "./workspaceChangeVersionPage.json"
import workspaceSchedulePage from "./workspaceSchedulePage.json"
import serviceBannerSettings from "./serviceBannerSettings.json"
import appearanceSettings from "./appearanceSettings.json"
import starterTemplatesPage from "./starterTemplatesPage.json"
import starterTemplatePage from "./starterTemplatePage.json"
import createTemplatePage from "./createTemplatePage.json"
@ -34,7 +34,7 @@ export const en = {
loginPage,
workspaceChangeVersionPage,
workspaceSchedulePage,
serviceBannerSettings,
appearanceSettings,
starterTemplatesPage,
starterTemplatePage,
createTemplatePage,

View File

@ -0,0 +1,278 @@
import Button from "@material-ui/core/Button"
import FormControlLabel from "@material-ui/core/FormControlLabel"
import FormHelperText from "@material-ui/core/FormHelperText"
import InputAdornment from "@material-ui/core/InputAdornment"
import { useTheme } from "@material-ui/core/styles"
import makeStyles from "@material-ui/core/styles/makeStyles"
import Switch from "@material-ui/core/Switch"
import TextField from "@material-ui/core/TextField"
import { useActor } from "@xstate/react"
import { FeatureNames } from "api/types"
import { AppearanceConfig } from "api/typesGenerated"
import {
Badges,
DisabledBadge,
EnterpriseBadge,
EntitledBadge,
} from "components/DeploySettingsLayout/Badges"
import { Fieldset } from "components/DeploySettingsLayout/Fieldset"
import { Header } from "components/DeploySettingsLayout/Header"
import { Stack } from "components/Stack/Stack"
import { useFormik } from "formik"
import React, { useContext, useState } from "react"
import { BlockPicker } from "react-color"
import { Helmet } from "react-helmet-async"
import { useTranslation } from "react-i18next"
import { getFormHelpers } from "util/formUtils"
import { pageTitle } from "util/page"
import { XServiceContext } from "xServices/StateContext"
// ServiceBanner is unlike the other Deployment Settings pages because it
// implements a form, whereas the others are read-only. We make this
// exception because the Service Banner is visual, and configuring it from
// the command line would be a significantly worse user experience.
const AppearanceSettingsPage: React.FC = () => {
const xServices = useContext(XServiceContext)
const [appearanceXService, appearanceSend] = useActor(
xServices.appearanceXService,
)
const [entitlementsState] = useActor(xServices.entitlementsXService)
const appearance = appearanceXService.context.appearance
const styles = useStyles()
const isEntitled =
entitlementsState.context.entitlements.features[FeatureNames.Appearance]
.entitlement !== "not_entitled"
const updateAppearance = (
newConfig: Partial<AppearanceConfig>,
preview: boolean,
) => {
const newAppearance = {
...appearance,
...newConfig,
}
if (preview) {
appearanceSend({
type: "SET_PREVIEW_APPEARANCE",
appearance: newAppearance,
})
return
}
appearanceSend({
type: "SET_APPEARANCE",
appearance: newAppearance,
})
}
const logoForm = useFormik<{
logo_url: string
}>({
initialValues: {
logo_url: appearance.logo_url,
},
onSubmit: (values) => updateAppearance(values, false),
})
const logoFieldHelpers = getFormHelpers(logoForm)
const serviceBannerForm = useFormik<AppearanceConfig["service_banner"]>({
initialValues: {
message: appearance.service_banner.message,
enabled: appearance.service_banner.enabled,
background_color: appearance.service_banner.background_color,
},
onSubmit: (values) =>
updateAppearance(
{
service_banner: values,
},
false,
),
})
const serviceBannerFieldHelpers = getFormHelpers(serviceBannerForm)
const [backgroundColor, setBackgroundColor] = useState(
serviceBannerForm.values.background_color,
)
const theme = useTheme()
const [t] = useTranslation("appearanceSettings")
return (
<>
<Helmet>
<title>{pageTitle("Appearance Settings")}</title>
</Helmet>
<Header
title="Appearance"
description="Customize the look and feel of your Coder deployment."
/>
<Badges>
{isEntitled ? <EntitledBadge /> : <DisabledBadge />}
<EnterpriseBadge />
</Badges>
<Fieldset
title="Logo URL"
validation="We recommend a transparent image with 3:1 aspect ratio."
onSubmit={logoForm.handleSubmit}
>
<p>
Specify a custom URL for your logo to be displayed in the top left
corner of the dashboard.
</p>
<TextField
{...logoFieldHelpers("logo_url")}
defaultValue={appearance.logo_url}
fullWidth
placeholder="Leave empty to display the Coder logo."
InputProps={{
endAdornment: (
<InputAdornment position="end" className={styles.logoAdornment}>
<img
alt=""
src={logoForm.values.logo_url}
// This prevent browser to display the ugly error icon if the
// image path is wrong or user didn't finish typing the url
onError={(e) => (e.currentTarget.style.display = "none")}
onLoad={(e) => (e.currentTarget.style.display = "inline")}
/>
</InputAdornment>
),
}}
/>
</Fieldset>
<Fieldset
title="Service Banner"
onSubmit={serviceBannerForm.handleSubmit}
button={
!isEntitled && (
<Button
onClick={() => {
updateAppearance(
{
service_banner: {
message:
"👋 **This** is a service banner. The banner's color and text are editable.",
background_color: "#004852",
enabled: true,
},
},
true,
)
}}
>
{t("showPreviewLabel")}
</Button>
)
}
validation={
!isEntitled && (
<p>
Your license does not include Service Banners.{" "}
<a href="mailto:sales@coder.com">Contact sales</a> to learn more.
</p>
)
}
>
<p>Configure a banner that displays a message to all users.</p>
{isEntitled && (
<Stack>
<FormControlLabel
control={
<Switch
color="primary"
checked={serviceBannerForm.values.enabled}
onChange={async () => {
const newState = !serviceBannerForm.values.enabled
const newBanner = {
...serviceBannerForm.values,
enabled: newState,
}
updateAppearance(
{
service_banner: newBanner,
},
false,
)
await serviceBannerForm.setFieldValue("enabled", newState)
}}
/>
}
label="Enabled"
/>
<Stack spacing={0}>
<TextField
{...serviceBannerFieldHelpers("message")}
fullWidth
label="Message"
variant="outlined"
multiline
/>
<FormHelperText>{t("messageHelperText")}</FormHelperText>
</Stack>
<Stack spacing={0}>
<h3>{"Background Color"}</h3>
<BlockPicker
color={backgroundColor}
onChange={async (color) => {
setBackgroundColor(color.hex)
await serviceBannerForm.setFieldValue(
"background_color",
color.hex,
)
updateAppearance(
{
service_banner: {
...serviceBannerForm.values,
background_color: color.hex,
},
},
true,
)
}}
triangle="hide"
colors={["#004852", "#D65D0F", "#4CD473", "#D94A5D", "#5A00CF"]}
styles={{
default: {
input: {
color: "white",
backgroundColor: theme.palette.background.default,
},
body: {
backgroundColor: "black",
color: "white",
},
card: {
backgroundColor: "black",
},
},
}}
/>
</Stack>
</Stack>
)}
</Fieldset>
</>
)
}
const useStyles = makeStyles((theme) => ({
form: {
maxWidth: "500px",
},
logoAdornment: {
width: theme.spacing(3),
height: theme.spacing(3),
"& img": {
maxWidth: "100%",
},
},
}))
export default AppearanceSettingsPage

View File

@ -1,231 +0,0 @@
import TextField from "@material-ui/core/TextField"
import { useActor } from "@xstate/react"
import { FeatureNames } from "api/types"
import {
Badges,
DisabledBadge,
EnterpriseBadge,
EntitledBadge,
} from "components/DeploySettingsLayout/Badges"
import { Header } from "components/DeploySettingsLayout/Header"
import { LoadingButton } from "components/LoadingButton/LoadingButton"
import { Stack } from "components/Stack/Stack"
import { FormikContextType, useFormik } from "formik"
import React, { useContext, useState } from "react"
import { Helmet } from "react-helmet-async"
import { pageTitle } from "util/page"
import * as Yup from "yup"
import { XServiceContext } from "xServices/StateContext"
import { getFormHelpers } from "util/formUtils"
import makeStyles from "@material-ui/core/styles/makeStyles"
import FormControlLabel from "@material-ui/core/FormControlLabel"
import Switch from "@material-ui/core/Switch"
import { BlockPicker } from "react-color"
import { useTheme } from "@material-ui/core/styles"
import FormHelperText from "@material-ui/core/FormHelperText"
import Button from "@material-ui/core/Button"
import { useTranslation } from "react-i18next"
export interface ServiceBannerFormValues {
message?: string
backgroundColor?: string
enabled: boolean
}
// TODO:
const validationSchema = Yup.object({})
// ServiceBanner is unlike the other Deployment Settings pages because it
// implements a form, whereas the others are read-only. We make this
// exception because the Service Banner is visual, and configuring it from
// the command line would be a significantly worse user experience.
const ServiceBannerSettingsPage: React.FC = () => {
const xServices = useContext(XServiceContext)
const [serviceBannerState, serviceBannerSend] = useActor(
xServices.serviceBannerXService,
)
const [entitlementsState] = useActor(xServices.entitlementsXService)
const serviceBanner = serviceBannerState.context.serviceBanner
const styles = useStyles()
const isEntitled =
entitlementsState.context.entitlements.features[FeatureNames.ServiceBanners]
.entitlement !== "not_entitled"
const setBanner = (values: ServiceBannerFormValues, preview: boolean) => {
const newBanner = {
message: values.message,
enabled: values.enabled,
background_color: values.backgroundColor,
}
if (preview) {
serviceBannerSend({
type: "SET_PREVIEW_BANNER",
serviceBanner: newBanner,
})
return
}
serviceBannerSend({
type: "SET_BANNER",
serviceBanner: newBanner,
})
}
const initialValues: ServiceBannerFormValues = {
message: serviceBanner.message,
enabled: serviceBanner.enabled,
backgroundColor: serviceBanner.background_color,
}
const form: FormikContextType<ServiceBannerFormValues> =
useFormik<ServiceBannerFormValues>({
initialValues,
validationSchema,
onSubmit: (values) => setBanner(values, false),
})
const getFieldHelpers = getFormHelpers<ServiceBannerFormValues>(form)
const [backgroundColor, setBackgroundColor] = useState(
form.values.backgroundColor,
)
const theme = useTheme()
const [t] = useTranslation("serviceBannerSettings")
return (
<>
<Helmet>
<title>{pageTitle("Service Banner Settings")}</title>
</Helmet>
<Header
title="Service Banner"
description="Configure a banner that displays a message to all users"
docsHref="https://coder.com/docs/coder-oss/latest/admin/service-banners"
/>
<Badges>
{isEntitled ? <EntitledBadge /> : <DisabledBadge />}
<EnterpriseBadge />
</Badges>
{isEntitled ? (
<form className={styles.form} onSubmit={form.handleSubmit}>
<Stack>
<FormControlLabel
control={
<Switch
color="primary"
checked={form.values.enabled}
onChange={() => {
const newState = !form.values.enabled
const newBanner = {
...form.values,
enabled: newState,
}
setBanner(newBanner, false)
form.setFieldValue("enabled", newState)
}}
/>
}
label="Enabled"
/>
<Stack spacing={0}>
<TextField
{...getFieldHelpers("message")}
fullWidth
label="Message"
variant="outlined"
multiline
onChange={(e) => {
form.setFieldValue("message", e.target.value)
setBanner(
{
...form.values,
message: e.target.value,
},
true,
)
}}
/>
<FormHelperText>{t("messageHelperText")}</FormHelperText>
</Stack>
<Stack spacing={0}>
<h3>{"Background Color"}</h3>
<BlockPicker
color={backgroundColor}
onChange={(color) => {
setBackgroundColor(color.hex)
form.setFieldValue("backgroundColor", color.hex)
setBanner(
{
...form.values,
backgroundColor: color.hex,
},
true,
)
}}
triangle="hide"
colors={["#004852", "#D65D0F", "#4CD473", "#D94A5D", "#5A00CF"]}
styles={{
default: {
input: {
color: "white",
backgroundColor: theme.palette.background.default,
},
body: {
backgroundColor: "black",
color: "white",
},
card: {
backgroundColor: "black",
},
},
}}
/>
</Stack>
<Stack direction="row">
<LoadingButton loading={false} type="submit" variant="contained">
{t("updateLabel")}
</LoadingButton>
</Stack>
</Stack>
</form>
) : (
<>
<p>
Your license does not include Service Banners.{" "}
<a href="mailto:sales@coder.com">Contact sales</a> to learn more.
</p>
<Button
onClick={() => {
setBanner(
{
message:
"👋 **This** is a service banner. The banner's color and text are editable.",
backgroundColor: "#004852",
enabled: true,
},
true,
)
}}
>
{t("showPreviewLabel")}
</Button>
</>
)}
</>
)
}
const useStyles = makeStyles(() => ({
form: {
maxWidth: "500px",
},
}))
export default ServiceBannerSettingsPage

View File

@ -1097,3 +1097,10 @@ export const MockPermissions: Permissions = {
viewAuditLog: true,
viewDeploymentConfig: true,
}
export const MockAppearance: TypesGen.AppearanceConfig = {
logo_url: "",
service_banner: {
enabled: false,
},
}

View File

@ -282,4 +282,8 @@ export const handlers = [
rest.get("/api/v2/workspace-quota/:userId", (req, res, ctx) => {
return res(ctx.status(200), ctx.json(MockWorkspaceQuota))
}),
rest.get("/api/v2/appearance", (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockAppearance))
}),
]

View File

@ -7,13 +7,13 @@ import { updateCheckMachine } from "./updateCheck/updateCheckXService"
import { deploymentConfigMachine } from "./deploymentConfig/deploymentConfigMachine"
import { entitlementsMachine } from "./entitlements/entitlementsXService"
import { siteRolesMachine } from "./roles/siteRolesXService"
import { serviceBannerMachine } from "./serviceBanner/serviceBannerXService"
import { appearanceMachine } from "./appearance/appearanceXService"
interface XServiceContextType {
authXService: ActorRefFrom<typeof authMachine>
buildInfoXService: ActorRefFrom<typeof buildInfoMachine>
entitlementsXService: ActorRefFrom<typeof entitlementsMachine>
serviceBannerXService: ActorRefFrom<typeof serviceBannerMachine>
appearanceXService: ActorRefFrom<typeof appearanceMachine>
siteRolesXService: ActorRefFrom<typeof siteRolesMachine>
// Since the info here is used by multiple deployment settings page and we don't want to refetch them every time
deploymentConfigXService: ActorRefFrom<typeof deploymentConfigMachine>
@ -37,7 +37,7 @@ export const XServiceProvider: FC<{ children: ReactNode }> = ({ children }) => {
authXService: useInterpret(authMachine),
buildInfoXService: useInterpret(buildInfoMachine),
entitlementsXService: useInterpret(entitlementsMachine),
serviceBannerXService: useInterpret(serviceBannerMachine),
appearanceXService: useInterpret(appearanceMachine),
siteRolesXService: useInterpret(siteRolesMachine),
deploymentConfigXService: useInterpret(deploymentConfigMachine),
updateCheckXService: useInterpret(updateCheckMachine),

View File

@ -0,0 +1,137 @@
import { displaySuccess } from "components/GlobalSnackbar/utils"
import { assign, createMachine } from "xstate"
import * as API from "../../api/api"
import { AppearanceConfig } from "../../api/typesGenerated"
export const Language = {
getAppearanceError: "Error getting appearance.",
setAppearanceError: "Error setting appearance.",
}
export type AppearanceContext = {
appearance: AppearanceConfig
getAppearanceError?: Error | unknown
setAppearanceError?: Error | unknown
preview: boolean
}
export type AppearanceEvent =
| {
type: "GET_APPEARANCE"
}
| { type: "SET_PREVIEW_APPEARANCE"; appearance: AppearanceConfig }
| { type: "SET_APPEARANCE"; appearance: AppearanceConfig }
const emptyAppearance: AppearanceConfig = {
logo_url: "",
service_banner: {
enabled: false,
},
}
export const appearanceMachine = createMachine(
{
id: "appearanceMachine",
predictableActionArguments: true,
tsTypes: {} as import("./appearanceXService.typegen").Typegen0,
schema: {
context: {} as AppearanceContext,
events: {} as AppearanceEvent,
services: {
getAppearance: {
data: {} as AppearanceConfig,
},
setAppearance: {
data: {},
},
},
},
context: {
appearance: emptyAppearance,
preview: false,
},
initial: "gettingAppearance",
states: {
idle: {
on: {
GET_APPEARANCE: "gettingAppearance",
SET_PREVIEW_APPEARANCE: "settingPreviewAppearance",
SET_APPEARANCE: "settingAppearance",
},
},
gettingAppearance: {
entry: "clearGetAppearanceError",
invoke: {
id: "getAppearance",
src: "getAppearance",
onDone: {
target: "idle",
actions: ["assignAppearance"],
},
onError: {
target: "idle",
actions: ["assignGetAppearanceError"],
},
},
},
settingPreviewAppearance: {
entry: [
"clearGetAppearanceError",
"clearSetAppearanceError",
"assignPreviewAppearance",
],
always: {
target: "idle",
},
},
settingAppearance: {
entry: "clearSetAppearanceError",
invoke: {
id: "setAppearance",
src: "setAppearance",
onDone: {
target: "idle",
actions: ["assignAppearance", "notifyUpdateAppearanceSuccess"],
},
onError: {
target: "idle",
actions: ["assignSetAppearanceError"],
},
},
},
},
},
{
actions: {
assignPreviewAppearance: assign({
appearance: (_, event) => event.appearance,
// The xState docs suggest that we can use a static value, but I failed
// to find a way to do that that doesn't generate type errors.
preview: (_, __) => true,
}),
notifyUpdateAppearanceSuccess: () => {
displaySuccess("Successfully updated appearance settings!")
},
assignAppearance: assign({
appearance: (_, event) => event.data as AppearanceConfig,
preview: (_, __) => false,
}),
assignGetAppearanceError: assign({
getAppearanceError: (_, event) => event.data,
}),
clearGetAppearanceError: assign({
getAppearanceError: (_) => undefined,
}),
assignSetAppearanceError: assign({
setAppearanceError: (_, event) => event.data,
}),
clearSetAppearanceError: assign({
setAppearanceError: (_) => undefined,
}),
},
services: {
getAppearance: API.getAppearance,
setAppearance: (_, event) => API.updateAppearance(event.appearance),
},
},
)

View File

@ -1,134 +0,0 @@
import { displaySuccess } from "components/GlobalSnackbar/utils"
import { assign, createMachine } from "xstate"
import * as API from "../../api/api"
import { ServiceBanner } from "../../api/typesGenerated"
export const Language = {
getServiceBannerError: "Error getting service banner.",
setServiceBannerError: "Error setting service banner.",
}
export type ServiceBannerContext = {
serviceBanner: ServiceBanner
getServiceBannerError?: Error | unknown
setServiceBannerError?: Error | unknown
preview: boolean
}
export type ServiceBannerEvent =
| {
type: "GET_BANNER"
}
| { type: "SET_PREVIEW_BANNER"; serviceBanner: ServiceBanner }
| { type: "SET_BANNER"; serviceBanner: ServiceBanner }
const emptyBanner = {
enabled: false,
}
export const serviceBannerMachine = createMachine(
{
id: "serviceBannerMachine",
predictableActionArguments: true,
tsTypes: {} as import("./serviceBannerXService.typegen").Typegen0,
schema: {
context: {} as ServiceBannerContext,
events: {} as ServiceBannerEvent,
services: {
getServiceBanner: {
data: {} as ServiceBanner,
},
setServiceBanner: {
data: {},
},
},
},
context: {
serviceBanner: emptyBanner,
preview: false,
},
initial: "idle",
states: {
idle: {
on: {
GET_BANNER: "gettingBanner",
SET_PREVIEW_BANNER: "settingPreviewBanner",
SET_BANNER: "settingBanner",
},
},
gettingBanner: {
entry: "clearGetBannerError",
invoke: {
id: "getBanner",
src: "getBanner",
onDone: {
target: "idle",
actions: ["assignBanner"],
},
onError: {
target: "idle",
actions: ["assignGetBannerError"],
},
},
},
settingPreviewBanner: {
entry: [
"clearGetBannerError",
"clearSetBannerError",
"assignPreviewBanner",
],
always: {
target: "idle",
},
},
settingBanner: {
entry: "clearSetBannerError",
invoke: {
id: "setBanner",
src: "setBanner",
onDone: {
target: "idle",
actions: ["assignBanner", "notifyUpdateBannerSuccess"],
},
onError: {
target: "idle",
actions: ["assignSetBannerError"],
},
},
},
},
},
{
actions: {
assignPreviewBanner: assign({
serviceBanner: (_, event) => event.serviceBanner,
// The xState docs suggest that we can use a static value, but I failed
// to find a way to do that that doesn't generate type errors.
preview: (_, __) => true,
}),
notifyUpdateBannerSuccess: () => {
displaySuccess("Successfully updated Service Banner!")
},
assignBanner: assign({
serviceBanner: (_, event) => event.data as ServiceBanner,
preview: (_, __) => false,
}),
assignGetBannerError: assign({
getServiceBannerError: (_, event) => event.data,
}),
clearGetBannerError: assign({
getServiceBannerError: (_) => undefined,
}),
assignSetBannerError: assign({
setServiceBannerError: (_, event) => event.data,
}),
clearSetBannerError: assign({
setServiceBannerError: (_) => undefined,
}),
},
services: {
getBanner: API.getServiceBanner,
setBanner: (_, event) => API.setServiceBanner(event.serviceBanner),
},
},
)