added branding settings

Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
RonniSkansing committed 2026-09-17 21:56:06 +02:00
1 parent 6588de6317
commit 556e25077c
18 files changed
+1372 -30

No files matched your search

+13
View File
@@ -103,6 +103,11 @@ const (
// auto-prune options
ROUTE_V1_OPTION_AUTO_PRUNE = "/api/v1/option/auto-prune"
ROUTE_V1_COMPANY_OPTION_AUTO_PRUNE = "/api/v1/company/:id/option/auto-prune"
// branding
ROUTE_V1_BRANDING = "/api/v1/branding"
ROUTE_V1_BRANDING_IMAGE_SLOT = "/api/v1/branding/image/:slot"
ROUTE_V1_BRANDING_DISPLAY_SLOT = "/api/v1/branding/display/:slot"
ROUTE_V1_BRANDING_SIDE_VISIBLE = "/api/v1/branding/login-side-image/visibility"
// installation
ROUTE_V1_INSTALL = "/api/v1/install"
ROUTE_V1_INSTALL_TEMPLATES = "/api/v1/install/templates"
@@ -400,6 +405,14 @@ func setupRoutes(
POST(ROUTE_V1_OPTION_AUTO_PRUNE, middleware.SessionHandler, controllers.Option.SetAutoPrune).
GET(ROUTE_V1_COMPANY_OPTION_AUTO_PRUNE, middleware.SessionHandler, controllers.Option.GetCompanyAutoPrune).
POST(ROUTE_V1_COMPANY_OPTION_AUTO_PRUNE, middleware.SessionHandler, controllers.Option.SetCompanyAutoPrune).
// branding, the state and image reads are public so the pre login screen
// can render a custom logo and side image before authentication
GET(ROUTE_V1_BRANDING, controllers.Branding.GetState).
GET(ROUTE_V1_BRANDING_IMAGE_SLOT, controllers.Branding.GetImage).
POST(ROUTE_V1_BRANDING_IMAGE_SLOT, middleware.SessionHandler, controllers.Branding.Upload).
DELETE(ROUTE_V1_BRANDING_IMAGE_SLOT, middleware.SessionHandler, controllers.Branding.Reset).
POST(ROUTE_V1_BRANDING_DISPLAY_SLOT, middleware.SessionHandler, controllers.Branding.SetDisplay).
POST(ROUTE_V1_BRANDING_SIDE_VISIBLE, middleware.SessionHandler, controllers.Branding.SetSideImageVisibility).
// domain
GET(ROUTE_V1_DOMAIN, middleware.SessionHandler, controllers.Domain.GetAll).
GET(ROUTE_V1_DOMAIN_SUBSET, middleware.SessionHandler, controllers.Domain.GetAllOverview).
+6
View File
@@ -45,6 +45,7 @@ type Controllers struct {
Scim *controller.Scim
RemoteBrowser *controller.RemoteBrowserController
ReportTemplate *controller.ReportTemplate
Branding *controller.Branding
}
// NewControllers creates a collection of controllers
@@ -235,6 +236,10 @@ func NewControllers(
OptionService: services.Option,
ExecPath: conf.RemoteBrowser.ExecPath,
}
branding := &controller.Branding{
Common: common,
BrandingService: services.Branding,
}
return &Controllers{
Asset: asset,
@@ -273,5 +278,6 @@ func NewControllers(
Scim: scim,
RemoteBrowser: remoteBrowser,
ReportTemplate: reportTemplate,
Branding: branding,
}
}
+10
View File
@@ -48,6 +48,7 @@ type Services struct {
Scim *service.Scim
RemoteBrowser *service.RemoteBrowser
ReportTemplate *service.ReportTemplate
Branding *service.Branding
}
// NewServices creates a collection of services
@@ -65,6 +66,7 @@ func NewServices(
filePath string,
trustedProxies []string,
remoteBrowserExecPath string,
brandingPath string,
) *Services {
common := service.Common{
Logger: logger,
@@ -332,6 +334,13 @@ func NewServices(
ReportSendLogRepository: repositories.ReportSendLog,
}
brandingService := &service.Branding{
Common: common,
RootFolder: brandingPath,
OptionRepository: repositories.Option,
FileService: file,
}
return &Services{
CompanyScimConfig: companyScimConfig,
CompanyReportConfig: companyReportConfig,
@@ -369,5 +378,6 @@ func NewServices(
MicrosoftDeviceCode: microsoftDeviceCodeService,
RemoteBrowser: remoteBrowser,
ReportTemplate: reportTemplate,
Branding: brandingService,
}
}
+138
View File
@@ -0,0 +1,138 @@
package controller
import (
"io"
"net/http"
"github.com/gin-gonic/gin"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/service"
)
// Branding is the controller for install wide UI branding.
type Branding struct {
Common
BrandingService *service.Branding
}
// GetState returns the branding mode of each slot. It is public so the login
// screen can read it before authentication.
func (c *Branding) GetState(g *gin.Context) {
state, err := c.BrandingService.GetState(g.Request.Context())
if err != nil {
c.Response.ServerError(g)
return
}
// never cache the state so an admin change is picked up on the next load
g.Header("Cache-Control", "no-store")
c.Response.OK(g, state)
}
// GetImage streams the uploaded PNG for a slot. It is public so the login
// screen can show a custom logo and side image before authentication. When no
// custom image is stored it returns 404 so the frontend falls back to the
// built in default.
func (c *Branding) GetImage(g *gin.Context) {
slot := g.Param("slot")
content, found, err := c.BrandingService.GetImage(slot)
if err != nil {
c.Response.ServerError(g)
return
}
if !found {
c.Response.NotFound(g)
return
}
g.Header("Cache-Control", "no-cache")
g.Header("X-Content-Type-Options", "nosniff")
g.Data(http.StatusOK, "image/png", content)
}
// Upload validates and stores an uploaded PNG for a slot.
func (c *Branding) Upload(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
slot := g.Param("slot")
fileHeader, err := g.FormFile("file")
if err != nil {
c.Response.BadRequestMessage(g, "No file selected")
return
}
// reject an oversized upload before reading it into memory
if fileHeader.Size > data.BrandingMaxUploadBytes {
c.Response.BadRequestMessage(g, "File is too large")
return
}
f, err := fileHeader.Open()
if err != nil {
c.Response.BadRequest(g)
return
}
defer f.Close()
// cap the read so an oversized upload can not exhaust memory, one byte over
// the limit so the size validation still rejects it
content, err := io.ReadAll(io.LimitReader(f, data.BrandingMaxUploadBytes+1))
if err != nil {
c.Response.BadRequest(g)
return
}
err = c.BrandingService.SetImage(g.Request.Context(), session, slot, content)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// Reset removes the uploaded image for a slot, returning it to the default.
func (c *Branding) Reset(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
slot := g.Param("slot")
err := c.BrandingService.Reset(g.Request.Context(), session, slot)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// SetDisplay stores the display fit settings for a slot.
func (c *Branding) SetDisplay(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
slot := g.Param("slot")
var req service.BrandingDisplay
if ok := c.handleParseRequest(g, &req); !ok {
return
}
err := c.BrandingService.SetDisplay(g.Request.Context(), session, slot, req)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
// SetSideImageVisibility shows or hides the login side image. Hiding centers
// the login form.
func (c *Branding) SetSideImageVisibility(g *gin.Context) {
session, _, ok := c.handleSession(g)
if !ok {
return
}
var req struct {
Hidden bool `json:"hidden"`
}
if ok := c.handleParseRequest(g, &req); !ok {
return
}
err := c.BrandingService.SetSideImageHidden(g.Request.Context(), session, req.Hidden)
if ok := c.handleErrors(g, err); !ok {
return
}
c.Response.OK(g, gin.H{})
}
+69
View File
@@ -0,0 +1,69 @@
package data
const (
// BrandingSlotHeaderLogo is the logo shown in the app header
BrandingSlotHeaderLogo = "header-logo"
// BrandingSlotLoginLogo is the logo shown on the login screen
BrandingSlotLoginLogo = "login-logo"
// BrandingSlotLoginSideImage is the image shown beside the login form
BrandingSlotLoginSideImage = "login-side-image"
// BrandingModeDefault means the built in asset is used
BrandingModeDefault = "default"
// BrandingModeCustom means an uploaded image is used
BrandingModeCustom = "custom"
// OptionKeyBrandingLoginSideImageRemoved marks the login side image as hidden
OptionKeyBrandingLoginSideImageRemoved = "branding_login_side_image_removed"
// OptionKeyBrandingDisplay holds the per slot display settings as a JSON map
OptionKeyBrandingDisplay = "branding_display"
// BrandingMaxUploadBytes is the largest accepted branding image
BrandingMaxUploadBytes = 5 * 1024 * 1024
// BrandingMaxImageDimension bounds width and height to stop decompression bombs
BrandingMaxImageDimension = 4096
// display fit modes, map to CSS object-fit
BrandingFitContain = "contain"
BrandingFitCover = "cover"
BrandingFitFill = "fill"
// display background behind the image
BrandingBackgroundNone = "none"
BrandingBackgroundLight = "light"
BrandingBackgroundDark = "dark"
// display scale is a percentage bound so a value can not break the layout
BrandingScaleMin = 25
BrandingScaleMax = 200
BrandingScaleDefault = 100
)
// BrandingFits are the accepted fit modes
var BrandingFits = map[string]bool{
BrandingFitContain: true,
BrandingFitCover: true,
BrandingFitFill: true,
}
// BrandingBackgrounds are the accepted background values
var BrandingBackgrounds = map[string]bool{
BrandingBackgroundNone: true,
BrandingBackgroundLight: true,
BrandingBackgroundDark: true,
}
// BrandingPositionsX are the accepted horizontal positions
var BrandingPositionsX = map[string]bool{"left": true, "center": true, "right": true}
// BrandingPositionsY are the accepted vertical positions
var BrandingPositionsY = map[string]bool{"top": true, "center": true, "bottom": true}
// BrandingSlotFilename maps a branding slot to its on disk PNG filename. Only
// slots present here are accepted, so a request can not name an arbitrary path.
var BrandingSlotFilename = map[string]string{
BrandingSlotHeaderLogo: "header-logo.png",
BrandingSlotLoginLogo: "login-logo.png",
BrandingSlotLoginSideImage: "login-side-image.png",
}
+2
View File
@@ -131,6 +131,7 @@ func main() {
ownManagedTLSPath := fmt.Sprintf("%scerts/own-managed", *flagFilePath)
assetPath := fmt.Sprintf("%sassets", *flagFilePath)
attachmentsPath := fmt.Sprintf("%sattachments", *flagFilePath)
brandingPath := fmt.Sprintf("%sbranding", *flagFilePath)
// print banner and version
cli.PrintBanner()
@@ -249,6 +250,7 @@ func main() {
*flagFilePath,
conf.IPSecurity.TrustedProxies,
conf.RemoteBrowser.ExecPath,
brandingPath,
)
// get entra-id options and setup msal client
ssoOpt, err := services.SSO.GetSSOOptionWithoutAuth(context.Background())
+411
View File
@@ -0,0 +1,411 @@
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"image/png"
"net/http"
"os"
"path/filepath"
"github.com/go-errors/errors"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"github.com/phishingclub/phishingclub/vo"
"gorm.io/gorm"
)
// Branding is a service for install wide UI branding. It stores uploaded PNG
// images on disk and tracks whether the login side image has been hidden.
type Branding struct {
Common
RootFolder string
OptionRepository *repository.Option
FileService *File
}
// BrandingDisplay is how an image is fitted within its area on screen.
type BrandingDisplay struct {
Fit string `json:"fit"`
Scale int `json:"scale"`
Background string `json:"background"`
PositionX string `json:"positionX"`
PositionY string `json:"positionY"`
}
// BrandingState is the branding state returned to the frontend. Each slot mode
// is 'default' or 'custom'. The login side image can additionally be hidden,
// which is independent of whether a custom image is stored. Display holds the
// per slot fit settings keyed by slot.
type BrandingState struct {
HeaderLogo string `json:"headerLogo"`
LoginLogo string `json:"loginLogo"`
LoginSideImage string `json:"loginSideImage"`
LoginSideImageHidden bool `json:"loginSideImageHidden"`
Display map[string]BrandingDisplay `json:"display"`
}
// GetState returns the branding state. There is no authorization check as the
// login screen reads this before a user is authenticated.
func (b *Branding) GetState(ctx context.Context) (*BrandingState, error) {
hidden, err := b.isSideImageRemoved(ctx)
if err != nil {
return nil, errs.Wrap(err)
}
display, err := b.getDisplayMap(ctx)
if err != nil {
return nil, errs.Wrap(err)
}
return &BrandingState{
HeaderLogo: b.slotMode(data.BrandingSlotHeaderLogo),
LoginLogo: b.slotMode(data.BrandingSlotLoginLogo),
LoginSideImage: b.slotMode(data.BrandingSlotLoginSideImage),
LoginSideImageHidden: hidden,
Display: display,
}, nil
}
// defaultDisplay is the fit settings for a slot when none are stored. Logos
// show whole (contain); the side image fills its area (cover).
func defaultDisplay(slot string) BrandingDisplay {
fit := data.BrandingFitContain
if slot == data.BrandingSlotLoginSideImage {
fit = data.BrandingFitCover
}
// the login logo sits top left like the original; other slots center
posX, posY := "center", "center"
if slot == data.BrandingSlotLoginLogo {
posX, posY = "left", "top"
}
return BrandingDisplay{
Fit: fit,
Scale: data.BrandingScaleDefault,
Background: data.BrandingBackgroundNone,
PositionX: posX,
PositionY: posY,
}
}
// getDisplayMap returns the stored display settings for every slot, filling in
// defaults for any slot or field that is unset.
func (b *Branding) getDisplayMap(ctx context.Context) (map[string]BrandingDisplay, error) {
stored := map[string]BrandingDisplay{}
opt, err := b.OptionRepository.GetByKey(ctx, data.OptionKeyBrandingDisplay)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errs.Wrap(err)
}
if err == nil && opt.Value.String() != "" {
if uErr := json.Unmarshal([]byte(opt.Value.String()), &stored); uErr != nil {
b.Logger.Errorw("failed to parse branding display settings", "error", uErr)
stored = map[string]BrandingDisplay{}
}
}
out := map[string]BrandingDisplay{}
for slot := range data.BrandingSlotFilename {
out[slot] = withDisplayDefaults(slot, stored[slot])
}
return out, nil
}
// withDisplayDefaults fills any empty or out of range field with its default.
func withDisplayDefaults(slot string, d BrandingDisplay) BrandingDisplay {
def := defaultDisplay(slot)
if !data.BrandingFits[d.Fit] {
d.Fit = def.Fit
}
if !data.BrandingBackgrounds[d.Background] {
d.Background = def.Background
}
if !data.BrandingPositionsX[d.PositionX] {
d.PositionX = def.PositionX
}
if !data.BrandingPositionsY[d.PositionY] {
d.PositionY = def.PositionY
}
if d.Scale < data.BrandingScaleMin || d.Scale > data.BrandingScaleMax {
d.Scale = def.Scale
}
return d
}
// SetDisplay stores the display settings for a slot.
func (b *Branding) SetDisplay(
ctx context.Context,
session *model.Session,
slot string,
display BrandingDisplay,
) error {
ae := NewAuditEvent("Branding.SetDisplay", session)
ae.Details["slot"] = slot
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
b.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
b.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
if _, ok := data.BrandingSlotFilename[slot]; !ok {
return errs.NewValidationError(fmt.Errorf("unknown branding slot"))
}
// clamp and default any invalid field so a bad value can not break rendering
display = withDisplayDefaults(slot, display)
stored := map[string]BrandingDisplay{}
opt, getErr := b.OptionRepository.GetByKey(ctx, data.OptionKeyBrandingDisplay)
if getErr != nil && !errors.Is(getErr, gorm.ErrRecordNotFound) {
return errs.Wrap(getErr)
}
if getErr == nil && opt.Value.String() != "" {
if uErr := json.Unmarshal([]byte(opt.Value.String()), &stored); uErr != nil {
// corrupt value, start fresh rather than fail; log so it is visible
b.Logger.Errorw("failed to parse branding display settings", "error", uErr)
stored = map[string]BrandingDisplay{}
}
}
stored[slot] = display
blob, err := json.Marshal(stored)
if err != nil {
return errs.Wrap(err)
}
if err := b.upsertOption(ctx, data.OptionKeyBrandingDisplay, string(blob)); err != nil {
return errs.Wrap(err)
}
b.AuditLogAuthorized(ae)
return nil
}
// GetImage returns the uploaded PNG bytes for a slot. The found flag is false
// when no custom image is stored, so the caller falls back to the built in
// default. There is no authorization check as branding images are shown on the
// pre login screen.
func (b *Branding) GetImage(slot string) ([]byte, bool, error) {
filename, ok := data.BrandingSlotFilename[slot]
if !ok {
return nil, false, nil
}
content, err := os.ReadFile(filepath.Join(b.RootFolder, filename))
if err != nil {
if os.IsNotExist(err) {
return nil, false, nil
}
b.Logger.Errorw("failed to read branding image", "slot", slot, "error", err)
return nil, false, errs.Wrap(err)
}
return content, true, nil
}
// SetImage validates and stores an uploaded PNG for a slot.
func (b *Branding) SetImage(
ctx context.Context,
session *model.Session,
slot string,
content []byte,
) error {
ae := NewAuditEvent("Branding.SetImage", session)
ae.Details["slot"] = slot
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
b.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
b.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
filename, ok := data.BrandingSlotFilename[slot]
if !ok {
return errs.NewValidationError(fmt.Errorf("unknown branding slot"))
}
if err := validatePNG(content); err != nil {
return err
}
// write through an os.Root sandbox using the shared file service, the same
// safe write path the asset and attachment uploads use
if err := os.MkdirAll(b.RootFolder, 0755); err != nil {
b.Logger.Errorw("failed to create branding folder", "error", err)
return errs.Wrap(err)
}
root, err := os.OpenRoot(b.RootFolder)
if err != nil {
b.Logger.Errorw("failed to open branding folder", "error", err)
return errs.Wrap(err)
}
defer root.Close()
if err := b.FileService.UploadFile(root, filename, bytes.NewBuffer(content), true); err != nil {
b.Logger.Errorw("failed to write branding image", "slot", slot, "error", err)
return errs.Wrap(err)
}
// uploading a side image clears any previous hidden state
if slot == data.BrandingSlotLoginSideImage {
if err := b.setSideImageRemoved(ctx, false); err != nil {
return errs.Wrap(err)
}
}
b.AuditLogAuthorized(ae)
return nil
}
// Reset removes any uploaded image for a slot, returning it to the built in
// default. For the login side image it also clears the hidden state.
func (b *Branding) Reset(
ctx context.Context,
session *model.Session,
slot string,
) error {
ae := NewAuditEvent("Branding.Reset", session)
ae.Details["slot"] = slot
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
b.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
b.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
filename, ok := data.BrandingSlotFilename[slot]
if !ok {
return errs.NewValidationError(fmt.Errorf("unknown branding slot"))
}
if err := os.Remove(filepath.Join(b.RootFolder, filename)); err != nil && !os.IsNotExist(err) {
b.Logger.Errorw("failed to remove branding image", "slot", slot, "error", err)
return errs.Wrap(err)
}
if slot == data.BrandingSlotLoginSideImage {
if err := b.setSideImageRemoved(ctx, false); err != nil {
return errs.Wrap(err)
}
}
b.AuditLogAuthorized(ae)
return nil
}
// SetSideImageHidden shows or hides the login side image. Hiding centers the
// login form. Any uploaded side image is kept so showing it again restores the
// custom image; use Reset to remove the uploaded image entirely.
func (b *Branding) SetSideImageHidden(
ctx context.Context,
session *model.Session,
hidden bool,
) error {
ae := NewAuditEvent("Branding.SetSideImageHidden", session)
ae.Details["hidden"] = hidden
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
b.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
b.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
if err := b.setSideImageRemoved(ctx, hidden); err != nil {
return errs.Wrap(err)
}
b.AuditLogAuthorized(ae)
return nil
}
// slotMode resolves whether a slot has a custom uploaded image.
func (b *Branding) slotMode(slot string) string {
if b.hasCustomImage(slot) {
return data.BrandingModeCustom
}
return data.BrandingModeDefault
}
// hasCustomImage reports whether an uploaded image exists for the slot.
func (b *Branding) hasCustomImage(slot string) bool {
filename, ok := data.BrandingSlotFilename[slot]
if !ok {
return false
}
_, err := os.Stat(filepath.Join(b.RootFolder, filename))
return err == nil
}
// isSideImageRemoved reads the hidden flag for the login side image.
func (b *Branding) isSideImageRemoved(ctx context.Context) (bool, error) {
opt, err := b.OptionRepository.GetByKey(ctx, data.OptionKeyBrandingLoginSideImageRemoved)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, errs.Wrap(err)
}
return opt.Value.String() == "true", nil
}
// setSideImageRemoved upserts the hidden flag for the login side image.
func (b *Branding) setSideImageRemoved(ctx context.Context, removed bool) error {
value := "false"
if removed {
value = "true"
}
return b.upsertOption(ctx, data.OptionKeyBrandingLoginSideImageRemoved, value)
}
// upsertOption inserts or updates a single option row by key.
func (b *Branding) upsertOption(ctx context.Context, key string, value string) error {
valueVO, err := vo.NewOptionalString1MB(value)
if err != nil {
return errs.NewValidationError(err)
}
opt := &model.Option{
Key: *vo.NewString127Must(key),
Value: *valueVO,
}
_, getErr := b.OptionRepository.GetByKey(ctx, key)
if getErr != nil {
if !errors.Is(getErr, gorm.ErrRecordNotFound) {
return errs.Wrap(getErr)
}
if _, insertErr := b.OptionRepository.Insert(ctx, opt); insertErr != nil {
return errs.Wrap(insertErr)
}
return nil
}
if updateErr := b.OptionRepository.UpdateByKey(ctx, opt); updateErr != nil {
return errs.Wrap(updateErr)
}
return nil
}
// validatePNG rejects anything that is not a small, sane PNG. This is the only
// upload type gate in the branding path, so it is strict: it checks the sniffed
// content type, decodes the image to prove it is a real PNG and not a polyglot,
// and bounds the dimensions to stop decompression bombs.
func validatePNG(content []byte) error {
if len(content) == 0 {
return errs.NewValidationError(fmt.Errorf("file is empty"))
}
if len(content) > data.BrandingMaxUploadBytes {
return errs.NewValidationError(fmt.Errorf("file is too large"))
}
sniffLen := len(content)
if sniffLen > 512 {
sniffLen = 512
}
if http.DetectContentType(content[:sniffLen]) != "image/png" {
return errs.NewValidationError(fmt.Errorf("file is not a png"))
}
cfg, err := png.DecodeConfig(bytes.NewReader(content))
if err != nil {
return errs.NewValidationError(fmt.Errorf("file is not a valid png"))
}
if cfg.Width <= 0 || cfg.Height <= 0 ||
cfg.Width > data.BrandingMaxImageDimension ||
cfg.Height > data.BrandingMaxImageDimension {
return errs.NewValidationError(fmt.Errorf("image dimensions are out of bounds"))
}
if _, err := png.Decode(bytes.NewReader(content)); err != nil {
return errs.NewValidationError(fmt.Errorf("file is not a valid png"))
}
return nil
}
+59
View File
@@ -3418,6 +3418,65 @@ export class API {
}
};
/**
* branding is the API for install wide UI branding. The state and image
* reads are public so the login screen can render a custom logo and side
* image before the user is authenticated.
*/
branding = {
/**
* @returns {Promise<ApiResponse>}
*/
getState: async () => {
return await getJSON(this.getPath(`/branding`));
},
/**
* imageURL returns the public URL for a branding slot image. An optional
* version keeps the header and login screen in sync right after an upload
* by busting the browser cache.
* @param {string} slot
* @param {number|string} [version]
* @returns {string}
*/
imageURL: (slot, version) => {
const path = this.getPath(`/branding/image/${slot}`);
return version ? `${path}?v=${version}` : path;
},
/**
* @param {string} slot
* @param {File} file
* @returns {Promise<ApiResponse>}
*/
upload: async (slot, file) => {
const formData = new FormData();
formData.append('file', file);
return await postMultipart(this.getPath(`/branding/image/${slot}`), formData);
},
/**
* @param {string} slot
* @returns {Promise<ApiResponse>}
*/
reset: async (slot) => {
return await deleteReq(this.getPath(`/branding/image/${slot}`));
},
/**
* @param {boolean} hidden
* @returns {Promise<ApiResponse>}
*/
setSideImageHidden: async (hidden) => {
return await postJSON(this.getPath(`/branding/login-side-image/visibility`), { hidden });
},
/**
* setDisplay stores how an image is fitted within its area.
* @param {string} slot
* @param {{fit:string, scale:number, background:string, positionX:string, positionY:string}} display
* @returns {Promise<ApiResponse>}
*/
setDisplay: async (slot, display) => {
return await postJSON(this.getPath(`/branding/display/${slot}`), display);
}
};
/**
* proxy is the API for Proxy related operations.
*/
@@ -0,0 +1,122 @@
<script>
// Compact adjustment controls for a branding image: fit, size, background and
// position. Uses the app's TextFieldSelect so the dropdowns match the rest of
// the settings UI. Emits 'input' live while dragging the size slider so the
// preview updates, and 'change' when a value is committed so it can be saved.
import { createEventDispatcher } from 'svelte';
import TextFieldSelect from '$lib/components/TextFieldSelect.svelte';
export let display = {};
export let disabled = false;
const dispatch = createEventDispatcher();
// unique id prefix so several instances on the page do not clash
const uid = Math.random().toString(36).slice(2, 8);
const fitOptions = [
{ value: 'contain', label: 'Contain' },
{ value: 'cover', label: 'Cover' },
{ value: 'fill', label: 'Stretch' }
];
const backgroundOptions = [
{ value: 'none', label: 'Transparent' },
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' }
];
const positionXOptions = [
{ value: 'left', label: 'Left' },
{ value: 'center', label: 'Center' },
{ value: 'right', label: 'Right' }
];
const positionYOptions = [
{ value: 'top', label: 'Top' },
{ value: 'center', label: 'Center' },
{ value: 'bottom', label: 'Bottom' }
];
let fit = 'contain';
let scale = 100;
let background = 'none';
let positionX = 'center';
let positionY = 'center';
// keep the controls in sync with the incoming settings
$: sync(display);
function sync(d) {
fit = d.fit || 'contain';
scale = d.scale ?? 100;
background = d.background || 'none';
positionX = d.positionX || 'center';
positionY = d.positionY || 'center';
}
const current = () => ({ fit, scale: Number(scale), background, positionX, positionY });
const live = () => dispatch('input', current());
const commit = () => dispatch('change', current());
const selectChanged = () => {
live();
commit();
};
const labelClass =
'text-xs font-semibold text-slate-500 dark:text-gray-400 mb-1 transition-colors duration-200';
</script>
<div class="mt-3 space-y-1">
<div class="grid grid-cols-2 gap-3">
<TextFieldSelect
id="{uid}-fit"
size="small"
bind:value={fit}
options={fitOptions}
onSelect={selectChanged}>Fit</TextFieldSelect
>
<TextFieldSelect
id="{uid}-bg"
size="small"
bind:value={background}
options={backgroundOptions}
onSelect={selectChanged}>Background</TextFieldSelect
>
<TextFieldSelect
id="{uid}-px"
size="small"
bind:value={positionX}
options={positionXOptions}
onSelect={selectChanged}>Horizontal</TextFieldSelect
>
<TextFieldSelect
id="{uid}-py"
size="small"
bind:value={positionY}
options={positionYOptions}
onSelect={selectChanged}>Vertical</TextFieldSelect
>
</div>
<label class="flex flex-col pt-1">
<span class={labelClass}>Size {scale}%</span>
<input
type="range"
min="25"
max="200"
step="5"
bind:value={scale}
on:input={live}
on:change={commit}
{disabled}
class="w-full accent-cta-blue dark:accent-highlight-blue"
/>
</label>
<div class="flex justify-end pt-1">
<button
type="button"
{disabled}
on:click={() => dispatch('reset')}
class="text-xs font-semibold text-cta-blue dark:text-highlight-blue hover:underline disabled:opacity-50 transition-colors duration-200"
>
Reset adjustments
</button>
</div>
</div>
@@ -0,0 +1,16 @@
<script>
// Renders a branding image inside a fixed box, applying the persisted display
// settings (fit, scale, background, position). The box is always bounded so an
// oversized upload can never break the layout.
import { brandingImageStyle, brandingBoxStyle } from '$lib/store/branding';
export let src;
export let alt = '';
export let display = {};
// sizing and rounding classes for the box, e.g. 'h-12 w-40 rounded-md'
export let boxClass = '';
</script>
<div class="flex {boxClass}" style={brandingBoxStyle(display)}>
<img {src} {alt} draggable="false" style={brandingImageStyle(display)} />
</div>
+20 -16
View File
@@ -42,23 +42,27 @@
</script>
<label class="flex flex-col py-2 w-56">
<div class="flex items-center">
<p class="font-semibold text-slate-600 dark:text-gray-300 py-2 transition-colors duration-200">
<slot />
</p>
{#if toolTipText.length > 0}
<ToolTip>
{toolTipText}
</ToolTip>
{/if}
{#if optional === true}
<div
class="bg-gray-100 dark:bg-gray-700 ml-2 px-2 rounded-md transition-colors duration-200 h-6 flex items-center"
{#if $$slots.default || toolTipText.length > 0 || optional === true}
<div class="flex items-center">
<p
class="font-semibold text-slate-600 dark:text-gray-300 py-2 transition-colors duration-200"
>
<p class="text-slate-600 dark:text-gray-300 text-xs">optional</p>
</div>
{/if}
</div>
<slot />
</p>
{#if toolTipText.length > 0}
<ToolTip>
{toolTipText}
</ToolTip>
{/if}
{#if optional === true}
<div
class="bg-gray-100 dark:bg-gray-700 ml-2 px-2 rounded-md transition-colors duration-200 h-6 flex items-center"
>
<p class="text-slate-600 dark:text-gray-300 text-xs">optional</p>
</div>
{/if}
</div>
{/if}
<input
id="files"
type="file"
+11 -1
View File
@@ -1,5 +1,15 @@
<script>
import { onMount } from 'svelte';
import { branding, loadBranding, headerLogoSrc, brandingDisplayFor, BRANDING_SLOT } from '$lib/store/branding';
import BrandingImage from '$lib/components/BrandingImage.svelte';
onMount(() => loadBranding());
$: logoSrc = headerLogoSrc($branding);
$: display = brandingDisplayFor($branding, BRANDING_SLOT.headerLogo);
</script>
<div
class="bg-gray-300 dark:bg-gray-700 w-full h-1/10 flex justify-center transition-colors duration-200"
>
<img alt="logo" src="/logo-white.svg" class="w-1/3 sm:w-1/4 lg:w-2/10 xl:w-1/10 2xl:w-1/10" />
<BrandingImage src={logoSrc} alt="logo" {display} boxClass="h-16 w-64 max-w-full" />
</div>
@@ -1,5 +1,12 @@
<script>
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { branding, loadBranding, headerLogoSrc, brandingDisplayFor, BRANDING_SLOT } from '$lib/store/branding';
import BrandingImage from '$lib/components/BrandingImage.svelte';
onMount(() => loadBranding());
$: logoSrc = headerLogoSrc($branding);
$: display = brandingDisplayFor($branding, BRANDING_SLOT.headerLogo);
</script>
<div
@@ -9,7 +16,7 @@
role="button"
class="flex items-center w-40 sm:w-40 md:w-42 lg:w-56 justify-center py-4 my-6 ml-4 cursor-pointer"
>
<img draggable="false" src="/logo-white.svg" alt="logo" />
<BrandingImage src={logoSrc} alt="logo" {display} boxClass="h-12 w-full rounded" />
</div>
<style>
@@ -1,9 +1,16 @@
<script>
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { menu, mobileTopMenu } from '$lib/consts/navigation';
import { shouldHideMenuItem } from '$lib/utils/common';
import ConditionalDisplay from '../ConditionalDisplay.svelte';
import ThemeToggle from '../ThemeToggle.svelte';
import { branding, loadBranding, headerLogoSrc, brandingDisplayFor, BRANDING_SLOT } from '$lib/store/branding';
import BrandingImage from '$lib/components/BrandingImage.svelte';
onMount(() => loadBranding());
$: logoSrc = headerLogoSrc($branding);
$: logoDisplay = brandingDisplayFor($branding, BRANDING_SLOT.headerLogo);
export let visible = false;
export let onClickLogout;
@@ -177,7 +184,7 @@
<div
class="mobile-menu-header flex justify-between h-20 items-center bg-pc-darkblue/90 dark:bg-gray-900/90 px-6 border-b border-white/10 dark:border-gray-700/50"
>
<img class="w-40 h-auto" src="/logo-white.svg" alt="logo" />
<BrandingImage src={logoSrc} alt="logo" display={logoDisplay} boxClass="h-12 w-40" />
<div class="flex items-center gap-2">
<div
class="flex items-center justify-center w-12 h-12 rounded-lg hover:bg-white/10 dark:hover:bg-gray-600/30 transition-all duration-200"
+127
View File
@@ -0,0 +1,127 @@
import { writable } from 'svelte/store';
import { api } from '$lib/api/apiProxy';
import { API } from '$lib/api/api.js';
// slot names, must match the backend data.BrandingSlot* constants
export const BRANDING_SLOT = {
headerLogo: 'header-logo',
loginLogo: 'login-logo',
loginSideImage: 'login-side-image'
};
// default assets bundled with the app, used when no custom image is uploaded
export const BRANDING_DEFAULTS = {
headerLogo: '/logo-white.svg',
loginLogoLight: '/logo-blue.svg',
loginLogoDark: '/logo-white.svg',
loginSideImage: '/login-graphics.svg'
};
// default display settings per slot, mirrors the backend defaults so the UI
// renders correctly before the state loads
const defaultDisplay = (slot) => ({
fit: slot === BRANDING_SLOT.loginSideImage ? 'cover' : 'contain',
scale: 100,
background: 'none',
// the login logo sits top left like the original; other slots center
positionX: slot === BRANDING_SLOT.loginLogo ? 'left' : 'center',
positionY: slot === BRANDING_SLOT.loginLogo ? 'top' : 'center'
});
export const BRANDING_DEFAULT_DISPLAY = {
[BRANDING_SLOT.headerLogo]: defaultDisplay(BRANDING_SLOT.headerLogo),
[BRANDING_SLOT.loginLogo]: defaultDisplay(BRANDING_SLOT.loginLogo),
[BRANDING_SLOT.loginSideImage]: defaultDisplay(BRANDING_SLOT.loginSideImage)
};
// branding holds the current branding state. Each slot mode is 'default' or
// 'custom'; the login side image can also be hidden.
export const branding = writable({
loaded: false,
headerLogo: 'default',
loginLogo: 'default',
loginSideImage: 'default',
loginSideImageHidden: false,
display: { ...BRANDING_DEFAULT_DISPLAY },
// version busts the image cache after an upload or reset
version: 0
});
// backgroundColor maps the background setting to a CSS color
const BRANDING_BG = { none: 'transparent', light: '#ffffff', dark: '#111827' };
// brandingImageStyle builds the inline style for an image element from its
// display settings. The image fills its box; object-fit and object-position
// place it, transform scales it, and the box supplies the background.
export const brandingImageStyle = (display) => {
const d = display || {};
const fit = d.fit || 'contain';
const scale = (d.scale ?? 100) / 100;
const posX = d.positionX || 'center';
const posY = d.positionY || 'center';
return (
`width:100%;height:100%;object-fit:${fit};` +
`object-position:${posX} ${posY};` +
`transform:scale(${scale});transform-origin:${posX} ${posY};`
);
};
// brandingBoxStyle builds the inline style for the box wrapping the image
export const brandingBoxStyle = (display) => {
const bg = BRANDING_BG[(display && display.background) || 'none'] || 'transparent';
return `background:${bg};overflow:hidden;`;
};
// brandingDisplayFor returns the display settings for a slot with defaults
export const brandingDisplayFor = (state, slot) =>
(state.display && state.display[slot]) || defaultDisplay(slot);
// guards against firing more than one initial fetch when several components
// mount at once
let inFlight = null;
// loadBranding fetches the branding state. Safe to call on the login screen
// before authentication as the endpoint is public. Concurrent calls share one
// in flight request; the state endpoint is never cached so each fetch is fresh.
export const loadBranding = async () => {
if (inFlight) {
return inFlight;
}
inFlight = (async () => {
try {
const res = await api.branding.getState();
if (res.success && res.data) {
branding.set({
loaded: true,
headerLogo: res.data.headerLogo,
loginLogo: res.data.loginLogo,
loginSideImage: res.data.loginSideImage,
loginSideImageHidden: !!res.data.loginSideImageHidden,
display: { ...BRANDING_DEFAULT_DISPLAY, ...(res.data.display || {}) },
version: Date.now()
});
} else {
branding.update((b) => ({ ...b, loaded: true }));
}
} catch (e) {
console.error('failed to load branding', e);
branding.update((b) => ({ ...b, loaded: true }));
} finally {
inFlight = null;
}
})();
return inFlight;
};
// brandingImageURL builds the public image URL for a slot. It must use the raw
// API instance, not the apiProxy: the proxy wraps every method as an async
// response handler, so calling this synchronous URL builder through it would
// return a Promise and the image would fail to load.
export const brandingImageURL = (slot, version) =>
API.instance.branding.imageURL(slot, version);
// headerLogoSrc resolves the header logo URL for the current state.
export const headerLogoSrc = (state) =>
state.headerLogo === 'custom'
? brandingImageURL(BRANDING_SLOT.headerLogo, state.version)
: BRANDING_DEFAULTS.headerLogo;
+47 -11
View File
@@ -18,6 +18,31 @@
import { page } from '$app/stores';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import { setupTheme, setupOSThemeListener, theme } from '$lib/theme.js';
import {
branding,
loadBranding,
brandingImageURL,
brandingDisplayFor,
BRANDING_SLOT,
BRANDING_DEFAULTS
} from '$lib/store/branding';
import BrandingImage from '$lib/components/BrandingImage.svelte';
// branding resolves the login logo and side image, falling back to the
// bundled defaults when no custom image is uploaded
$: loginLogoSrc =
$branding.loginLogo === 'custom'
? brandingImageURL(BRANDING_SLOT.loginLogo, $branding.version)
: $theme === 'dark'
? BRANDING_DEFAULTS.loginLogoDark
: BRANDING_DEFAULTS.loginLogoLight;
$: loginLogoDisplay = brandingDisplayFor($branding, BRANDING_SLOT.loginLogo);
$: sideImageRemoved = $branding.loginSideImageHidden;
$: sideImageSrc =
$branding.loginSideImage === 'custom'
? brandingImageURL(BRANDING_SLOT.loginSideImage, $branding.version)
: BRANDING_DEFAULTS.loginSideImage;
$: sideImageDisplay = brandingDisplayFor($branding, BRANDING_SLOT.loginSideImage);
// services
const appState = AppStateService.instance;
@@ -50,6 +75,8 @@
// initialize theme system
setupTheme();
setupOSThemeListener();
// load branding so a custom logo and side image show before login
loadBranding();
// if the user is already logged in, we want to redirect to the dashboard
if (appState.isLoggedIn()) {
@@ -234,7 +261,9 @@
<HeadTitle title="Sign in" />
<main
class="h-screen grid-cols-1 grid md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2 bg-white dark:bg-gray-900 transition-colors duration-200"
class="h-screen grid grid-cols-1 {sideImageRemoved
? ''
: 'lg:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2'} bg-white dark:bg-gray-900 transition-colors duration-200"
>
<!-- theme toggle -->
<div class="fixed top-3 right-6 z-50">
@@ -242,13 +271,16 @@
</div>
<div class="flex items-center justify-center h-full">
<img
class="fixed center top-6 w-1/4 md:w-1/4 lg:w-1/6 xl:w-1/6 2xl:w-1/6 lg:top-6 lg:left-4 xl:top-6 xl:left-4 2xl:top-6 2xl:left-4"
src={$theme === 'dark' ? '/logo-white.svg' : '/logo-blue.svg'}
<BrandingImage
src={loginLogoSrc}
alt="phishing club logo"
display={loginLogoDisplay}
boxClass="fixed top-6 left-4 z-40 w-1/4 md:w-1/4 lg:w-1/6 h-24"
/>
<div
class="flex flex-col items-center justify-center p-4 w-full sm:w-full md:w-3/4 lg:w-2/3 xl:w-2/3 2xl:w-2/3"
class="flex flex-col items-center justify-center p-4 w-full sm:w-full md:w-3/4 lg:w-2/3 xl:w-2/3 2xl:w-2/3 {sideImageRemoved
? 'lg:max-w-xl'
: ''}"
>
<div class="flex flex-col items-center justify-center w-full p-4">
<h1
@@ -439,10 +471,14 @@
</div>
</div>
</div>
<div class="flex">
<div
class="overflow-hidden hidden sm:hidden md:hidden lg:flex xl:flex 2xl:flex min-w-0 w-full h-full bg-right bg-cover"
style="background-image: url('/login-graphics.svg'); background-repeat: no-repeat; background-position: right center; background-size: cover;"
></div>
</div>
{#if !sideImageRemoved}
<div class="hidden lg:block h-full">
<BrandingImage
src={sideImageSrc}
alt="login side image"
display={sideImageDisplay}
boxClass="w-full h-full"
/>
</div>
{/if}
</main>
@@ -11,6 +11,7 @@
import Reports from './panels/Reports.svelte';
import RedTeam from './panels/RedTeam.svelte';
import System from './panels/System.svelte';
import Branding from './panels/Branding.svelte';
// Red Team panel is only relevant in red team phishing (blackbox) mode
$: tabs = [
@@ -22,6 +23,7 @@
...($displayMode === DISPLAY_MODE.BLACKBOX
? [{ id: 'redteam', label: 'Red Team', component: RedTeam }]
: []),
{ id: 'branding', label: 'Branding', component: Branding },
{ id: 'system', label: 'System', component: System }
];
@@ -0,0 +1,303 @@
<script>
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { api } from '$lib/api/apiProxy.js';
import { addToast } from '$lib/store/toast';
import {
branding,
loadBranding,
brandingImageURL,
brandingDisplayFor,
BRANDING_SLOT,
BRANDING_DEFAULTS,
BRANDING_DEFAULT_DISPLAY
} from '$lib/store/branding';
import SettingsCard from '$lib/components/SettingsCard.svelte';
import SettingsLoading from '$lib/components/SettingsLoading.svelte';
import Button from '$lib/components/Button.svelte';
import FileField from '$lib/components/FileField.svelte';
import CheckboxField from '$lib/components/CheckboxField.svelte';
import BrandingImage from '$lib/components/BrandingImage.svelte';
import BrandingAdjust from '$lib/components/BrandingAdjust.svelte';
let loaded = false;
// per slot busy flag so controls disable during a request
let busy = { 'header-logo': false, 'login-logo': false, 'login-side-image': false };
// hide state, driven one way from the store and updated by the toggle handler
// so it never fights a reactive assignment
let sideHidden = false;
// local editable copy of the display settings so the preview updates live
let forms = { 'header-logo': null, 'login-logo': null, 'login-side-image': null };
function syncForms() {
const b = get(branding);
sideHidden = b.loginSideImageHidden;
forms = {
'header-logo': { ...brandingDisplayFor(b, BRANDING_SLOT.headerLogo) },
'login-logo': { ...brandingDisplayFor(b, BRANDING_SLOT.loginLogo) },
'login-side-image': { ...brandingDisplayFor(b, BRANDING_SLOT.loginSideImage) }
};
}
onMount(async () => {
await loadBranding();
syncForms();
loaded = true;
});
// preview resolves the image shown in a card for a slot
$: headerPreview =
$branding.headerLogo === 'custom'
? brandingImageURL(BRANDING_SLOT.headerLogo, $branding.version)
: BRANDING_DEFAULTS.headerLogo;
$: loginPreview =
$branding.loginLogo === 'custom'
? brandingImageURL(BRANDING_SLOT.loginLogo, $branding.version)
: BRANDING_DEFAULTS.loginLogoLight;
$: sidePreview =
$branding.loginSideImage === 'custom'
? brandingImageURL(BRANDING_SLOT.loginSideImage, $branding.version)
: BRANDING_DEFAULTS.loginSideImage;
async function handleUpload(slot, event) {
const input = event.target;
const file = input.files && input.files[0];
if (!file) {
return;
}
if (file.type !== 'image/png') {
addToast('Only PNG images are allowed', 'Error');
input.value = '';
return;
}
busy[slot] = true;
try {
const res = await api.branding.upload(slot, file);
if (!res.success) {
addToast(res.error || 'Failed to upload image', 'Error');
return;
}
addToast('Image updated', 'Success');
await loadBranding();
syncForms();
} catch (e) {
addToast('Failed to upload image', 'Error');
console.error(e);
} finally {
busy[slot] = false;
input.value = '';
}
}
async function handleReset(slot) {
busy[slot] = true;
try {
const res = await api.branding.reset(slot);
if (!res.success) {
addToast(res.error || 'Failed to reset image', 'Error');
return;
}
addToast('Reset to default', 'Success');
await loadBranding();
syncForms();
} catch (e) {
addToast('Failed to reset image', 'Error');
console.error(e);
} finally {
busy[slot] = false;
}
}
async function handleToggleSideHidden(event) {
const hidden = event.target.checked;
const slot = BRANDING_SLOT.loginSideImage;
busy[slot] = true;
try {
const res = await api.branding.setSideImageHidden(hidden);
if (!res.success) {
addToast(res.error || 'Failed to update image', 'Error');
} else {
addToast(hidden ? 'Login side image hidden' : 'Login side image shown', 'Success');
}
} catch (e) {
addToast('Failed to update image', 'Error');
console.error(e);
} finally {
busy[slot] = false;
await loadBranding();
sideHidden = get(branding).loginSideImageHidden;
}
}
// live preview while adjusting
function onAdjustInput(slot, event) {
forms[slot] = event.detail;
forms = forms;
}
// persist a committed adjustment
async function onAdjustChange(slot, event) {
await persistDisplay(slot, event.detail);
}
// reset the adjustments for a slot back to its defaults
async function onAdjustReset(slot) {
const def = { ...BRANDING_DEFAULT_DISPLAY[slot] };
forms[slot] = def;
forms = forms;
await persistDisplay(slot, def);
}
async function persistDisplay(slot, display) {
forms[slot] = display;
forms = forms;
busy[slot] = true;
try {
const res = await api.branding.setDisplay(slot, display);
if (!res.success) {
addToast(res.error || 'Failed to save adjustment', 'Error');
}
await loadBranding();
forms[slot] = { ...brandingDisplayFor(get(branding), slot) };
forms = forms;
} catch (e) {
addToast('Failed to save adjustment', 'Error');
console.error(e);
} finally {
busy[slot] = false;
}
}
</script>
{#if !loaded}
<SettingsLoading />
{:else}
<div class="flex flex-wrap gap-6">
<SettingsCard title="Header logo">
<p class="text-gray-600 dark:text-gray-300 text-sm mb-4 transition-colors duration-200">
Shown in the top navigation. Upload a PNG, ideally with a transparent background.
</p>
<div class="h-24 rounded-md bg-gray-400 dark:bg-gray-900 mb-2 overflow-hidden">
<BrandingImage
src={headerPreview}
alt="header logo preview"
display={forms['header-logo']}
boxClass="h-full w-full"
/>
</div>
<FileField
accept="image/png"
resets={false}
disabled={busy['header-logo']}
on:change={(e) => handleUpload(BRANDING_SLOT.headerLogo, e)}
/>
<BrandingAdjust
display={forms['header-logo']}
disabled={busy['header-logo']}
on:input={(e) => onAdjustInput(BRANDING_SLOT.headerLogo, e)}
on:change={(e) => onAdjustChange(BRANDING_SLOT.headerLogo, e)}
on:reset={() => onAdjustReset(BRANDING_SLOT.headerLogo)}
/>
<svelte:fragment slot="footer">
{#if $branding.headerLogo === 'custom'}
<Button
size={'large'}
disabled={busy['header-logo']}
on:click={() => handleReset(BRANDING_SLOT.headerLogo)}
>
Reset to default
</Button>
{/if}
</svelte:fragment>
</SettingsCard>
<SettingsCard title="Login logo">
<p class="text-gray-600 dark:text-gray-300 text-sm mb-4 transition-colors duration-200">
Shown on the login screen. Upload a PNG, ideally with a transparent background.
</p>
<div class="h-24 rounded-md bg-gray-400 dark:bg-gray-900 mb-2 overflow-hidden">
<BrandingImage
src={loginPreview}
alt="login logo preview"
display={forms['login-logo']}
boxClass="h-full w-full"
/>
</div>
<FileField
accept="image/png"
resets={false}
disabled={busy['login-logo']}
on:change={(e) => handleUpload(BRANDING_SLOT.loginLogo, e)}
/>
<BrandingAdjust
display={forms['login-logo']}
disabled={busy['login-logo']}
on:input={(e) => onAdjustInput(BRANDING_SLOT.loginLogo, e)}
on:change={(e) => onAdjustChange(BRANDING_SLOT.loginLogo, e)}
on:reset={() => onAdjustReset(BRANDING_SLOT.loginLogo)}
/>
<svelte:fragment slot="footer">
{#if $branding.loginLogo === 'custom'}
<Button
size={'large'}
disabled={busy['login-logo']}
on:click={() => handleReset(BRANDING_SLOT.loginLogo)}
>
Reset to default
</Button>
{/if}
</svelte:fragment>
</SettingsCard>
<SettingsCard title="Login side image">
<p class="text-gray-600 dark:text-gray-300 text-sm mb-4 transition-colors duration-200">
The image beside the login form. Upload a PNG, or hide it to center the login box.
</p>
<div class="relative h-24 rounded-md bg-gray-400 dark:bg-gray-900 mb-2 overflow-hidden">
<BrandingImage
src={sidePreview}
alt="login side image preview"
display={forms['login-side-image']}
boxClass="h-full w-full {sideHidden ? 'opacity-30' : ''}"
/>
{#if sideHidden}
<span
class="absolute inset-0 flex items-center justify-center text-white text-xs font-semibold"
>
<span class="px-2 py-1 rounded bg-gray-900/70">Hidden, login box centered</span>
</span>
{/if}
</div>
<FileField
accept="image/png"
resets={false}
disabled={busy['login-side-image']}
on:change={(e) => handleUpload(BRANDING_SLOT.loginSideImage, e)}
/>
<BrandingAdjust
display={forms['login-side-image']}
disabled={busy['login-side-image']}
on:input={(e) => onAdjustInput(BRANDING_SLOT.loginSideImage, e)}
on:change={(e) => onAdjustChange(BRANDING_SLOT.loginSideImage, e)}
on:reset={() => onAdjustReset(BRANDING_SLOT.loginSideImage)}
/>
<CheckboxField
inline
value={sideHidden}
disabled={busy['login-side-image']}
on:change={handleToggleSideHidden}>Hide image</CheckboxField
>
<svelte:fragment slot="footer">
{#if $branding.loginSideImage === 'custom'}
<Button
size={'large'}
disabled={busy['login-side-image']}
on:click={() => handleReset(BRANDING_SLOT.loginSideImage)}
>
Reset to default
</Button>
{/if}
</svelte:fragment>
</SettingsCard>
</div>
{/if}