3
0

Add support for dark mode in Corteza studio

This commit is contained in:
kinyaelgrande 2023-11-29 14:25:05 +03:00 committed by Mumbi Francis
parent 58691c11e6
commit 0095663f60
73 changed files with 3199 additions and 785 deletions

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html data-color-mode="light" lang="en">
<head>
<!--
base location is root by default for all webapps

View File

@ -55,6 +55,11 @@ export default (options = {}) => {
this.$i18n.i18next.changeLanguage(user.meta.preferredLanguage)
}
// switch the webapp theme based on user preference
if (user.meta.theme) {
document.getElementsByTagName('html')[0].setAttribute('data-color-mode', user.meta.theme)
}
// ref to vue is needed inside compose helper
// load and register bundle and list of client/server scripts
const bundleLoaderOpt = {

View File

@ -19,25 +19,52 @@
<a :href="installSassDocs">{{ $t('installSassDocs') }}</a>
</div>
<b-row>
<b-col
v-for="key in Object.keys(brandingVariables)"
:key="key"
md="6"
cols="12"
<b-tabs
data-test-id="theme-tabs"
nav-wrapper-class="bg-white white border-bottom rounded-0"
card
>
<b-tab
v-for="theme in themes"
:key="theme.id"
:title="$t(`tabs.${theme.id}`)"
>
<b-form-group
:label="$t(`brandVariables.${key}`)"
label-class="text-primary"
>
<c-input-color-picker
v-model="brandingVariables[key]"
:data-test-id="`input-${key}-color`"
:translations="colorTranslations"
/>
</b-form-group>
</b-col>
</b-row>
<b-row>
<b-col
v-for="(key, index) in themeInputs"
:key="key"
md="6"
cols="12"
>
<b-form-group
:label="$t(`theme.values.${key}`)"
label-class="text-primary"
>
<c-input-color-picker
ref="picker"
v-model="theme.values[key]"
:data-test-id="`input-${key}-color`"
:translations="{
modalTitle: $t('colorPicker'),
cancelBtnLabel: $t('general:label.cancel'),
saveBtnLabel: $t('general:label.saveAndClose')
}"
>
<template v-slot:footer>
<b-button
variant="outline-primary"
class="mr-auto"
@click="resetColor(key, index, theme.id)"
>
{{ $t('label.default') }}
</b-button>
</template>
</c-input-color-picker>
</b-form-group>
</b-col>
</b-row>
</b-tab>
</b-tabs>
<template #footer>
<c-button-submit
@ -92,7 +119,22 @@ export default {
data () {
return {
brandingVariables: {
themeInputs: [
'white',
'black',
'primary',
'secondary',
'success',
'warning',
'danger',
'light',
'extra-light',
'dark',
'tertiary',
'gray-200',
'body-bg',
],
lightModeVariables: {
'white': '#FFFFFF',
'black': '#162425',
'primary': '#0B344E',
@ -107,10 +149,22 @@ export default {
'gray-200': '#F9FAFB',
'body-bg': '#F9FAFB',
},
colorTranslations: {
modalTitle: this.$t('colorPicker'),
saveBtnLabel: this.$t('general:label.saveAndClose'),
darkModeVariables: {
'white': '#162425',
'black': '#FFFFFF',
'primary': '#43AA8B',
'secondary': '#E4E9EF',
'success': '#E2A046',
'warning': '#758D9B',
'danger': '#E54122',
'light': '#5E727E',
'extra-light': '#F3F5F7',
'dark': '#0B344E',
'tertiary': '#F9FAFB',
'gray-200': '#162425',
'body-bg': '#162425',
},
themes: [],
}
},
@ -129,8 +183,24 @@ export default {
settings: {
immediate: true,
handler (settings) {
if (settings['ui.studio.branding-sass']) {
this.brandingVariables = JSON.parse(settings['ui.studio.branding-sass'])
if (settings['ui.studio.themes']) {
this.themes = settings['ui.studio.themes'].map(theme => {
theme.values = JSON.parse(theme.values)
return theme
})
} else {
this.themes = [
{
id: 'light',
title: this.$t('light'),
values: this.lightModeVariables,
},
{
id: 'dark',
title: this.$t('dark'),
values: this.darkModeVariables,
},
]
}
},
},
@ -138,8 +208,20 @@ export default {
methods: {
onSubmit () {
this.$emit('submit', { 'ui.studio.branding-sass': JSON.stringify(this.brandingVariables) })
this.$emit('submit', { 'ui.studio.themes': this.themes.map(theme => {
theme.values = JSON.stringify(theme.values)
return theme
}),
})
},
resetColor (key, index, mode) {
this.themes.forEach(theme => {
theme.values[key] = mode === 'light' ? this.lightModeVariables[key] : this.darkModeVariables[key]
})
this.$refs.picker[index].closeMenu()
},
},
}
</script>

View File

@ -12,39 +12,51 @@
</h3>
</template>
<c-ace-editor
v-model="customCSS"
lang="css"
height="300px"
font-size="14px"
show-line-numbers
:border="false"
:show-popout="true"
@open="openEditorModal"
/>
<b-modal
id="custom-css-editor"
v-model="showEditorModal"
:title="$t('modal.editor')"
cancel-variant="link"
size="lg"
:ok-title="$t('general:label.saveAndClose')"
:cancel-title="$t('general:label.cancel')"
body-class="p-0"
@ok="saveCustomCSSInput"
@hidden="resetCustomCSSInput"
<b-tabs
data-test-id="theme-tabs"
nav-wrapper-class="bg-white white border-bottom rounded-0"
card
>
<c-ace-editor
v-model="modalCSSInput"
lang="scss"
height="500px"
font-size="14px"
show-line-numbers
:border="false"
:show-popout="false"
/>
</b-modal>
<b-tab
v-for="theme in themes"
:key="theme.id"
:title="$t(`tabs.${theme.id}`)"
>
<c-ace-editor
v-model="theme.values"
lang="css"
height="300px"
font-size="14px"
show-line-numbers
:border="false"
:show-popout="true"
@open="openEditorModal(theme.id)"
/>
<b-modal
id="custom-css-editor"
v-model="theme.showEditorModal"
:title="$t('modal.editor')"
cancel-variant="link"
size="lg"
:ok-title="$t('general:label.saveAndClose')"
:cancel-title="$t('general:label.cancel')"
body-class="p-0"
@ok="saveCustomCSSInput(theme.id)"
@hidden="resetCustomCSSInput(theme.id)"
>
<c-ace-editor
v-model="theme.modalValue"
lang="scss"
height="500px"
font-size="14px"
show-line-numbers
:border="false"
:show-popout="false"
/>
</b-modal>
</b-tab>
</b-tabs>
<template #footer>
<c-button-submit
@ -99,9 +111,29 @@ export default {
data () {
return {
customCSS: '',
modalCSSInput: undefined,
showEditorModal: false,
themes: [
{
'id': 'general',
'title': 'General',
'values': '',
'modalValue': undefined,
showEditorModal: false,
},
{
'id': 'light',
'title': 'Light mode',
'values': '',
'modalValue': undefined,
showEditorModal: false,
},
{
'id': 'dark',
'title': 'Dark mode',
'values': '',
'modalValue': undefined,
showEditorModal: false,
},
],
}
},
@ -109,27 +141,57 @@ export default {
settings: {
immediate: true,
handler (settings) {
this.customCSS = settings['ui.custom-css'] || ''
if (settings['ui.studio.custom-css']) {
this.themes = settings['ui.studio.custom-css'].map((theme) => {
return {
id: theme.id,
title: theme.title,
values: theme.values,
modalValue: undefined,
showEditorModal: false,
}
})
}
},
},
},
methods: {
onSubmit () {
this.$emit('submit', { 'ui.custom-css': this.customCSS })
this.$emit('submit', {
'ui.studio.custom-css': this.themes.map((theme) => {
return {
id: theme.id,
title: theme.title,
values: theme.values,
}
}),
})
},
openEditorModal () {
this.modalCSSInput = this.customCSS
this.showEditorModal = true
openEditorModal (themeId) {
this.themes.forEach((theme) => {
if (theme.id === themeId) {
theme.modalValue = theme.values
theme.showEditorModal = true
}
})
},
saveCustomCSSInput () {
this.customCSS = this.modalCSSInput
saveCustomCSSInput (themeId) {
this.themes.forEach((theme) => {
if (theme.id === themeId) {
theme.values = theme.modalValue
}
})
},
resetCustomCSSInput () {
this.modalCSSInput = undefined
resetCustomCSSInput (themeId) {
this.themes.forEach((theme) => {
if (theme.id === themeId) {
theme.modalValue = undefined
}
})
},
},

View File

@ -139,8 +139,8 @@ export default {
.finally(() => {
this[type].processing = false
// Refresh the page if branding or custom CSS was updated
if (type === 'branding' || type === 'customCSS') {
// Refresh the page if branding variables is updated and sass installed or custom CSS was updated
if ((type === 'branding' && this.settings['ui.studio.sass-installed']) || type === 'customCSS') {
window.location.reload()
}
})

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html data-color-mode="light" lang="en">
<head>
<!--
base location is root by default for all webapps

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html data-color-mode="light" lang="en">
<head>
<!--
base location is root by default for all webapps

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html data-color-mode="light" lang="en">
<head>
<!--
base location is root by default for all webapps

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html data-color-mode="light" lang="en">
<head>
<!--
base location is root by default for all webapps

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html data-color-mode="light" lang="en">
<head>
<!--
base location is root by default for all webapps

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html data-color-mode="light" lang="en">
<head>
<!--
base location is root by default for all webapps

View File

@ -1442,6 +1442,7 @@ export default class System {
handle,
kind,
labels,
meta,
updatedAt,
} = (a as KV) || {}
if (!userID) {
@ -1463,6 +1464,7 @@ export default class System {
handle,
kind,
labels,
meta,
updatedAt,
}
return this.api().request(cfg).then(result => stdResolve(result))

View File

@ -15,6 +15,7 @@ interface UserMeta {
avatarKind?: string;
avatarColor?: string;
avatarBgColor?: string;
theme?: string;
}
interface SecurityPolicy {
@ -46,6 +47,7 @@ export class User {
avatarKind: '',
avatarColor: '',
avatarBgColor: '',
theme: '',
}
public canGrant = false

View File

@ -170,6 +170,16 @@
>
{{ labels.userSettingsChangePassword }}
</b-dropdown-item>
<b-dropdown
id="theme-dropleft"
dropleft
text="Theme"
variant="outline-link"
class="ml-2"
>
<b-dropdown-item @click="saveThemeMode('light')">Light</b-dropdown-item>
<b-dropdown-item @click="saveThemeMode('dark')">Dark</b-dropdown-item>
</b-dropdown>
<b-dropdown-divider />
<b-dropdown-item
data-test-id="dropdown-profile-logout"
@ -185,7 +195,18 @@
</template>
<script>
import { library } from '@fortawesome/fontawesome-svg-core'
import { faMoon, faSun} from '@fortawesome/free-solid-svg-icons'
library.add(faMoon, faSun)
export default {
data() {
return {
isThemeDropdownVisible: false,
}
},
props: {
sidebarPinned: {
type: Boolean,
@ -261,7 +282,35 @@ export default {
avatarExists () {
return this.$auth.user.meta.avatarID !== "0" && this.$auth.user.meta.avatarID
},
}
},
mounted() {
this.$root.$on('bv::dropdown::show', bvEvent => {
if(bvEvent.componentId === 'theme-dropleft') {
this.isThemeDropdownVisible = true;
}
})
this.$root.$on('bv::dropdown::hide', bvEvent => {
if(bvEvent.componentId === 'theme-dropleft') {
this.isThemeDropdownVisible = false;
}
if(this.isThemeDropdownVisible) {
bvEvent.preventDefault()
}
})
},
methods: {
async saveThemeMode (mode) {
this.$auth.user.meta.theme = mode
this.$SystemAPI.userUpdate(this.$auth.user)
.then(() => {
document.getElementsByTagName('html')[0].setAttribute('data-color-mode', mode)
})
.catch(console.error)
},
},
}
</script>

View File

@ -47,6 +47,7 @@ interface OAuth2TokenResponse {
email?: string;
preferred_language?: string;
avatarID?: string;
theme?: string;
}
interface PluginOpts {
@ -583,6 +584,9 @@ export class Auth {
u.meta.avatarID = oa2tkn.avatarID
// theme
u.meta.theme = oa2tkn.theme
this[accessToken] = oa2tkn.access_token
this[user] = u

View File

@ -27,25 +27,35 @@ editor:
colorPicker: Choose a color
sassNotInstalled: No dart-sass binaries were detected. For guidance on how to set it up, refer to
installSassDocs: docs here
brandVariables:
white: White
black: Black
primary: Primary
secondary: Secondary
success: Success
warning: Warning
danger: Danger
label:
default: Default
tabs:
light: Light
extra-light: Extra light
dark: Dark
tertiary: Tertiary
gray-200: Gray
body-bg: Body background
dark: Dark
theme:
values:
white: White
black: Black
primary: Primary
secondary: Secondary
success: Success
warning: Warning
danger: Danger
light: Light
extra-light: Extra light
dark: Dark
tertiary: Tertiary
gray-200: Gray
body-bg: Body background
custom-css:
title: Custom CSS
tabs:
general: General
light: Light
dark: Dark
modal:
editor: Custom CSS editor
editor: Custom CSS editor
topbar:
title: Topbar

View File

@ -34,6 +34,7 @@ import (
"github.com/cortezaproject/corteza/server/pkg/options"
"github.com/cortezaproject/corteza/server/pkg/provision"
"github.com/cortezaproject/corteza/server/pkg/rbac"
"github.com/cortezaproject/corteza/server/pkg/sass"
"github.com/cortezaproject/corteza/server/pkg/scheduler"
"github.com/cortezaproject/corteza/server/pkg/sentry"
"github.com/cortezaproject/corteza/server/pkg/valuestore"
@ -379,6 +380,7 @@ func (app *CortezaApp) InitServices(ctx context.Context) (err error) {
RBAC: app.Opt.RBAC,
Limit: app.Opt.Limit,
Attachment: app.Opt.Attachment,
Webapps: app.Opt.Webapp,
})
if err != nil {
return
@ -555,8 +557,15 @@ func (app *CortezaApp) Activate(ctx context.Context) (err error) {
updateDiscoverySettings(app.Opt.Discovery, service.CurrentSettings)
updateLocaleSettings(app.Opt.Locale)
updateSassInstallSettings(ctx, app.Log)
app.AuthService.Watch(ctx)
//Generate CSS for webapps
if err = service.GenerateCSS(sysService.CurrentSettings, app.Opt.Webapp.ScssDirPath); err != nil {
return fmt.Errorf("could not generate css for webapps: %w", err)
}
// messagebus reloader and consumer listeners
if app.Opt.Messagebus.Enabled {
@ -957,6 +966,20 @@ func updateSmtpSettings(log *zap.Logger, current *types.AppSettings) {
setupSmtpDialer(log, current.SMTP.Servers...)
}
func updateSassInstallSettings(ctx context.Context, log *zap.Logger) {
var sassInstalled bool
if sass.DartSassTranspiler(log) != nil {
sassInstalled = true
}
// update dart-sass installed setting
err := updateSetting(ctx, "ui.studio.sass-installed", sassInstalled)
if err != nil {
log.Warn("failed to set ui.studio.sass-installed setting", zap.Error(err))
}
}
func setupSmtpDialer(log *zap.Logger, servers ...types.SmtpServers) {
if len(servers) == 0 {
log.Warn("no SMTP servers found, email sending will be disabled")

View File

@ -57,7 +57,7 @@ func (app *CortezaApp) mountHttpRoutes(r chi.Router) {
return
}
r.Route(options.CleanBase(ho.WebappBaseUrl), webapp.MakeWebappServer(app.Log, ho, app.Opt.Auth, app.Opt.Discovery, app.Opt.Sentry, app.Opt.Webapp))
r.Route(options.CleanBase(ho.WebappBaseUrl), webapp.MakeWebappServer(app.Log, ho, app.Opt.Auth, app.Opt.Discovery, app.Opt.Sentry))
app.Log.Info(
"client web applications enabled",

View File

@ -1,24 +0,0 @@
// Do not forget to update getting-started/theming.md!
:root {
// Custom variable values only support SassScript inside `#{}`.
@each $color, $value in $colors {
--#{$color}: #{$value};
}
@each $color, $value in $theme-colors {
--#{$color}: #{$value};
}
@each $bp, $value in $grid-breakpoints {
--breakpoint-#{$bp}: #{$value};
}
@each $variable, $value in $corteza-specific {
--#{$variable}: #{$value};
}
// Use `inspect` for lists so that quoted items keep the quotes.
// See https://github.com/sass/sass/issues/2383#issuecomment-336349172
--font-family-sans-serif: #{inspect($font-family-sans-serif)};
--font-family-monospace: #{inspect($font-family-monospace)};
}

View File

@ -49,4 +49,4 @@
.alert-#{$color} {
@include alert-variant(theme-color-level($color, $alert-bg-level), theme-color-level($color, $alert-border-level), theme-color-level($color, $alert-color-level));
}
}
}

View File

@ -0,0 +1,47 @@
// Do not forget to update getting-started/theming.md!
@if $theme-mode == "light" {
/* stylelint-disable */
// This is used within corteza-webapp-compose/src/lib/block/Calendar/feedLoader/
// to determine default event colors
:export {
primary: $primary;
danger: $danger;
secondary: $secondary;
}
/* stylelint-enable */
:root,
[data-color-mode="light"] {
// Custom variable values only support SassScript inside `#{}`.
@each $color, $value in $colors {
--#{$color}: #{$value};
}
@each $color, $value in $theme-colors {
--#{$color}: #{$value};
}
@each $bp, $value in $grid-breakpoints {
--breakpoint-#{$bp}: #{$value};
}
@each $variable, $value in $corteza-specific {
--#{$variable}: #{$value};
}
// Use `inspect` for lists so that quoted items keep the quotes.
// See https://github.com/sass/sass/issues/2383#issuecomment-336349172
--font-family-sans-serif: #{inspect($font-family-sans-serif)};
--font-family-monospace: #{inspect($font-family-monospace)};
}
} @else {
[data-color-mode="#{$theme-mode}"] {
@each $color, $value in $theme-colors {
--#{$color}: #{$value};
}
@each $variable, $value in $corteza-specific {
--#{$variable}: #{$value};
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -132,13 +132,3 @@ $corteza-specific: map-merge(
),
$corteza-specific
);
/* stylelint-disable */
// This is used within corteza-webapp-compose/src/lib/block/Calendar/feedLoader/
// to determine default event colors
:export {
primary: $primary;
danger: $danger;
secondary: $secondary;
}
/* stylelint-enable */

View File

@ -1399,3 +1399,110 @@ $rfs-breakpoint-unit-cache: unit($rfs-breakpoint);
@include deprecate("The `float-none` mixin", "v4.3.0", "v5");
}
//bootstrap-vue
$b-toast-background-opacity: alpha($toast-background-color) !default;
@mixin bv-custom-range-validation-state($state, $color) {
.input-group .custom-range {
.was-validated &:#{$state},
&.is-#{$state} {
border-color: $color;
&:focus {
border-color: $color;
box-shadow: 0 0 0 $input-focus-width rgba($color, 0.25);
}
}
}
.custom-range {
.was-validated &:#{$state},
&.is-#{$state} {
// Pseudo-elements must be split across multiple rulesets to have an affect
&:focus {
&::-webkit-slider-thumb {
box-shadow: 0 0 0 1px $body-bg, 0 0 0 $input-btn-focus-width lighten($color, 35%);
}
&::-moz-range-thumb {
box-shadow: 0 0 0 1px $body-bg, 0 0 0 $input-btn-focus-width lighten($color, 35%);
}
&::-ms-thumb {
box-shadow: 0 0 0 1px $body-bg, 0 0 0 $input-btn-focus-width lighten($color, 35%);
}
}
&::-webkit-slider-thumb {
background-color: $color;
background-image: none;
&:active {
background-color: lighten($color, 35%);
background-image: none;
}
}
&::-webkit-slider-runnable-track {
background-color: rgba($color, 0.35);
}
&::-moz-range-thumb {
background-color: $color;
background-image: none;
&:active {
background-color: lighten($color, 35%);
background-image: none;
}
}
&::-moz-range-track {
background: rgba($color, 0.35);
}
~ .#{$state}-feedback,
~ .#{$state}-tooltip {
display: block;
}
&::-ms-thumb {
background-color: $color;
background-image: none;
&:active {
background-color: lighten($color, 35%);
background-image: none;
}
}
&::-ms-track-lower {
background: rgba($color, 0.35);
}
&::-ms-track-upper {
background: rgba($color, 0.35);
}
}
}
}
@mixin b-toast-variant($background, $border, $color) {
// Based on alert-variant mixin
.toast {
background-color: rgba(lighten($background, 5%), $b-toast-background-opacity);
border-color: rgba($border, $b-toast-background-opacity);
color: $color;
.toast-header {
color: $color;
background-color: rgba($background, $b-toast-background-opacity);
border-bottom-color: rgba($border, $b-toast-background-opacity);
}
// .toast-body[href] {
// color: darken($color, 10%);
// }
}
&.b-toast-solid {
.toast {
background-color: rgba(lighten($background, 5%), 1);
}
}
}

View File

@ -445,6 +445,9 @@ func (h AuthHandlers) handleTokenRequest(req *request.AuthReq, client *types.Aut
response["avatarID"] = strconv.FormatUint(user.Meta.AvatarID, 10)
}
//include user's theme
response["theme"] = user.Meta.Theme
// in case client is configured with "openid" scope,
// we'll add "id_token" with all required (by OIDC) details encoded
if strings.Contains(client.Scope, "openid") {

View File

@ -41,6 +41,7 @@ func Run(ctx context.Context, log *zap.Logger, s store.Storer, provisionOpt opti
func() error { return defaultAuthClient(ctx, log.Named("auth.clients"), s, authOpt) },
func() error { return addAuthSuperUsers(ctx, log.Named("auth.super-users"), s, authOpt) },
func() error { return invalidateDedupRules(ctx, log.Named("compose.deduplication"), s) },
func() error { return updateWebappTheme(ctx, log.Named("webapp.themes"), s) },
}
for _, fn := range ffn {

View File

@ -0,0 +1,97 @@
package provision
import (
"context"
"encoding/json"
"github.com/cortezaproject/corteza/server/store"
"github.com/cortezaproject/corteza/server/system/types"
"go.uber.org/zap"
"strconv"
"unicode"
)
func updateWebappTheme(ctx context.Context, log *zap.Logger, s store.Storer) (err error) {
vv, _, err := store.SearchSettingValues(ctx, s, types.SettingsFilter{})
if err != nil {
return err
}
oldCustomCSS := vv.FindByName("ui.custom-css")
oldBranding := vv.FindByName("ui.studio.branding-sass")
provisionTheme := func(name string, oldValue *types.SettingValue, themeIDs ...string) (err error) {
oldValueStr, err := strconv.Unquote(oldValue.Value.String())
if err != nil {
return err
}
var themes []types.Theme
for _, themeID := range themeIDs {
title := []rune(themeID)
title[0] = unicode.ToUpper(title[0])
if len(themeIDs) > 2 {
if themeID == "general" {
themes = append(themes, types.Theme{
ID: themeID,
Title: string(title),
Values: oldValueStr,
})
continue
}
themes = append(themes, types.Theme{
ID: themeID,
Title: string(title),
Values: "",
})
continue
}
themes = append(themes, types.Theme{
ID: themeID,
Title: string(title),
Values: oldValueStr,
})
}
value, err := json.Marshal(themes)
if err != nil {
return err
}
newThemeSetting := &types.SettingValue{
Name: name,
Value: value,
}
err = store.CreateSettingValue(ctx, s, newThemeSetting)
if err != nil {
log.Error("failed to provision webapp themes", zap.Error(err))
return err
}
// delete old custom css from the database
err = store.DeleteSettingValue(ctx, s, oldValue)
if err != nil {
return err
}
return nil
}
// provision custom CSS
if !oldCustomCSS.IsNull() {
err = provisionTheme("ui.studio.custom-css", oldCustomCSS, "general", "light", "dark")
if err != nil {
return err
}
}
// provision branding sass
if !oldBranding.IsNull() {
err = provisionTheme("ui.studio.themes", oldBranding, "light", "dark")
}
return nil
}

View File

@ -42,20 +42,44 @@ func DefaultCSS(log *zap.Logger, customCSS string) string {
processedCSS := strBuilder.String()
StylesheetCache.Set(
map[string]string{
"css": processedCSS,
},
)
StylesheetCache.Set("default-theme", processedCSS)
return processedCSS
}
func Transpiler(log *zap.Logger, brandingSass, customCSS, sassDirPath string, transpiler *godartsass.Transpiler) error {
var stringsBuilder strings.Builder
func Transpile(transpiler *godartsass.Transpiler, log *zap.Logger, themeID, themeSASS, customCSS, sassDirPath string) (err error) {
// process root section
err = processSass(transpiler, log, "root", themeID, themeSASS, customCSS, sassDirPath)
if err != nil {
return err
}
if brandingSass != "" {
err := jsonToSass(brandingSass, &stringsBuilder)
// process main section
err = processSass(transpiler, log, "main", themeID, themeSASS, customCSS, sassDirPath)
if err != nil {
return err
}
//process theme section
err = processSass(transpiler, log, "theme", themeID, themeSASS, customCSS, sassDirPath)
if err != nil {
return err
}
return nil
}
func processSass(transpiler *godartsass.Transpiler, log *zap.Logger, section, themeID, themeSASS, customCSS, sassDirPath string) (err error) {
var (
stringsBuilder strings.Builder
isCustomCssSyntaxValid bool
)
// add theme-mode variable to the top of the section
stringsBuilder.WriteString(fmt.Sprintf("$theme-mode: %s;\n", themeID))
if themeSASS != "" {
err = jsonToSass(themeSASS, &stringsBuilder)
if err != nil {
log.Error("failed to unmarshal branding sass variables", zap.Error(err))
return err
@ -63,20 +87,34 @@ func Transpiler(log *zap.Logger, brandingSass, customCSS, sassDirPath string, tr
}
if customCSS != "" {
// Get SASS variables from the custom CSS editor and give them precedence over branding variables
customVariables := sassVariablesPattern.FindAllString(customCSS, -1)
for _, customVariable := range customVariables {
stringsBuilder.WriteString(fmt.Sprintf("%s \n", customVariable))
_, err = transpileSass(transpiler, customCSS)
if err != nil {
log.Error("sass compilation for custom css failed", zap.Error(err))
} else {
isCustomCssSyntaxValid = true
// Get SASS variables from the custom CSS editor and give them precedence over branding variables
customVariables := sassVariablesPattern.FindAllString(customCSS, -1)
for _, customVariable := range customVariables {
stringsBuilder.WriteString(fmt.Sprintf("%s \n", customVariable))
}
}
}
// get Boostrap, bootstrap-vue and custom variables sass content
mainSass, err := readSassFiles(log, "scss")
//Save branding sass variables to cache
sassVariables, err := readSassFiles(log, "scss/variables")
if err != nil {
return err
}
stringsBuilder.WriteString(mainSass)
stringsBuilder.WriteString(sassVariables)
StylesheetCache.Set("sass", stringsBuilder.String())
// start processing a section
sassSection, err := readSassFiles(log, path.Join("scss", section))
if err != nil {
return err
}
stringsBuilder.WriteString(StylesheetCache.Get("sass"))
stringsBuilder.WriteString(sassSection)
// when a user provides sets WEBAPP_SCSS_DIR_PATH environment variable
if sassDirPath != "" {
@ -87,33 +125,49 @@ func Transpiler(log *zap.Logger, brandingSass, customCSS, sassDirPath string, tr
stringsBuilder.WriteString(customSass)
}
if customCSS != "" {
if customCSS != "" && isCustomCssSyntaxValid {
//Custom CSS editor selector block
selectorBlock := sassVariablesPattern.ReplaceAllString(customCSS, "")
stringsBuilder.WriteString(selectorBlock)
}
// compute sass content to CSS
args := godartsass.Args{
Source: stringsBuilder.String(),
}
execute, err := transpiler.Execute(args)
transpiledCss, err := transpileSass(transpiler, stringsBuilder.String())
if err != nil {
log.Error("sass compilation failure", zap.Error(err))
return err
}
// save computed css to Stylesheet in-memory cache
StylesheetCache.Set(
map[string]string{
"css": execute.CSS,
},
)
// in case of sass error in custom css sass compilation,
// use compiled css from branding sass and append custom css value to it
if !isCustomCssSyntaxValid {
stringsBuilder.Reset()
stringsBuilder.WriteString(transpiledCss)
stringsBuilder.WriteString(customCSS)
// append un-compiled custom css content to transpiled css
transpiledCss = stringsBuilder.String()
}
//save the transpiled css to stylesheet cache
sectionKey := fmt.Sprintf("%s-%s", section, themeID)
StylesheetCache.Set(sectionKey, transpiledCss)
return nil
}
func DartSass(log *zap.Logger) *godartsass.Transpiler {
// transpileSass computes sass to css by the transpiler
func transpileSass(transpiler *godartsass.Transpiler, sass string) (string, error) {
args := godartsass.Args{
Source: sass,
}
execute, err := transpiler.Execute(args)
if err != nil {
return "", err
}
return execute.CSS, nil
}
func DartSassTranspiler(log *zap.Logger) *godartsass.Transpiler {
transpiler, err := godartsass.Start(godartsass.Options{
DartSassEmbeddedFilename: "sass",
})

View File

@ -13,10 +13,11 @@ func newStylesheetCache() *stylesheetCache {
}
}
func (c *stylesheetCache) Set(value map[string]string) {
func (c *stylesheetCache) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.stylesheet = value
c.stylesheet[key] = value
}
func (c *stylesheetCache) Get(key string) string {
@ -30,7 +31,17 @@ func (c *stylesheetCache) Get(key string) string {
return value
}
func (c *stylesheetCache) Empty() bool {
func (c *stylesheetCache) Keys() (keys []string) {
c.mu.RLock()
defer c.mu.RUnlock()
for k := range c.stylesheet {
keys = append(keys, k)
}
return
}
func (c *stylesheetCache) IsEmpty() bool {
c.mu.RLock()
defer c.mu.RUnlock()

View File

@ -3,7 +3,6 @@ package webapp
import (
"bytes"
"fmt"
"github.com/cortezaproject/corteza/server/pkg/auth"
"io"
"net/http"
"os"
@ -27,7 +26,6 @@ type (
webappBaseUrl string
discoveryApiBaseUrl string
sentryUrl string
scssDirPath string
settings *types.AppSettings
}
)
@ -36,7 +34,7 @@ var (
baseHrefMatcher = regexp.MustCompile(`<base\s+href="?.+?"?\s*\/?>`)
)
func MakeWebappServer(log *zap.Logger, httpSrvOpt options.HttpServerOpt, authOpt options.AuthOpt, discoveryOpt options.DiscoveryOpt, sentryOpt options.SentryOpt, webappOpt options.WebappOpt) func(r chi.Router) {
func MakeWebappServer(log *zap.Logger, httpSrvOpt options.HttpServerOpt, authOpt options.AuthOpt, discoveryOpt options.DiscoveryOpt, sentryOpt options.SentryOpt) func(r chi.Router) {
var (
apiBaseUrl = options.CleanBase(httpSrvOpt.BaseUrl, httpSrvOpt.ApiBaseUrl)
webappSentryUrl = sentryOpt.WebappDSN
@ -74,7 +72,6 @@ func MakeWebappServer(log *zap.Logger, httpSrvOpt options.HttpServerOpt, authOpt
discoveryApiBaseUrl: discoveryApiBaseUrl,
sentryUrl: webappSentryUrl,
settings: service.CurrentSettings,
scssDirPath: webappOpt.ScssDirPath,
})
r.Get(webBaseUrl+"*", serveIndex(httpSrvOpt, appIndexHTMLs[app], fs))
}
@ -88,7 +85,6 @@ func MakeWebappServer(log *zap.Logger, httpSrvOpt options.HttpServerOpt, authOpt
discoveryApiBaseUrl: discoveryApiBaseUrl,
sentryUrl: webappSentryUrl,
settings: service.CurrentSettings,
scssDirPath: webappOpt.ScssDirPath,
})
r.Get(webBaseUrl+"*", serveIndex(httpSrvOpt, appIndexHTMLs[""], fs))
}
@ -152,12 +148,7 @@ func serveConfig(r chi.Router, config webappConfig) {
r.Get(options.CleanBase(config.appUrl, "custom.css"), func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Content-Type", "text/css")
ctx := auth.SetIdentityToContext(r.Context(), auth.ServiceUser())
stylesheet, err := service.GenerateCSS(ctx, config.settings.UI.Studio.BrandingSASS, config.settings.UI.CustomCSS, config.scssDirPath)
if err != nil {
logger.Default().Error("failed to generate CSS", zap.Error(err))
}
stylesheet := service.FetchCSS()
_, _ = fmt.Fprint(w, stylesheet)
})

View File

@ -572,6 +572,10 @@ endpoints:
name: labels
title: Labels
parser: label.ParseStrings
- name: meta
type: "*types.UserMeta"
title: Additional user info
parser: types.ParseUserMeta
- { type: "*time.Time", name: updatedAt, required: false, title: Last update (or creation) date }
- name: partialUpdate
method: PATCH

View File

@ -176,6 +176,11 @@ type (
// Labels
Labels map[string]string
// Meta POST parameter
//
// Additional user info
Meta *types.UserMeta
// UpdatedAt POST parameter
//
// Last update (or creation) date
@ -780,6 +785,7 @@ func (r UserUpdate) Auditable() map[string]interface{} {
"handle": r.Handle,
"kind": r.Kind,
"labels": r.Labels,
"meta": r.Meta,
"updatedAt": r.UpdatedAt,
}
}
@ -814,6 +820,11 @@ func (r UserUpdate) GetLabels() map[string]string {
return r.Labels
}
// Auditable returns all auditable/loggable parameters
func (r UserUpdate) GetMeta() *types.UserMeta {
return r.Meta
}
// Auditable returns all auditable/loggable parameters
func (r UserUpdate) GetUpdatedAt() *time.Time {
return r.UpdatedAt
@ -880,6 +891,18 @@ func (r *UserUpdate) Fill(req *http.Request) (err error) {
}
}
if val, ok := req.MultipartForm.Value["meta[]"]; ok {
r.Meta, err = types.ParseUserMeta(val)
if err != nil {
return err
}
} else if val, ok := req.MultipartForm.Value["meta"]; ok {
r.Meta, err = types.ParseUserMeta(val)
if err != nil {
return err
}
}
if val, ok := req.MultipartForm.Value["updatedAt"]; ok && len(val) > 0 {
r.UpdatedAt, err = payload.ParseISODatePtrWithErr(val[0])
if err != nil {
@ -936,6 +959,18 @@ func (r *UserUpdate) Fill(req *http.Request) (err error) {
}
}
if val, ok := req.Form["meta[]"]; ok {
r.Meta, err = types.ParseUserMeta(val)
if err != nil {
return err
}
} else if val, ok := req.Form["meta"]; ok {
r.Meta, err = types.ParseUserMeta(val)
if err != nil {
return err
}
}
if val, ok := req.Form["updatedAt"]; ok && len(val) > 0 {
r.UpdatedAt, err = payload.ParseISODatePtrWithErr(val[0])
if err != nil {

View File

@ -153,6 +153,7 @@ func (ctrl User) Update(ctx context.Context, r *request.UserUpdate) (interface{}
Handle: r.Handle,
Kind: r.Kind,
Labels: r.Labels,
Meta: r.Meta,
UpdatedAt: r.UpdatedAt,
}

View File

@ -40,6 +40,7 @@ type (
RBAC options.RbacOpt
Limit options.LimitOpt
Attachment options.AttachmentOpt
Webapps options.WebappOpt
}
eventDispatcher interface {
@ -153,7 +154,7 @@ func Initialize(ctx context.Context, log *zap.Logger, s store.Storer, ws websock
DefaultAccessControl = AccessControl(s)
DefaultSettings = Settings(ctx, DefaultStore, DefaultLogger, DefaultAccessControl, DefaultActionlog, CurrentSettings)
DefaultSettings = Settings(ctx, DefaultStore, DefaultLogger, DefaultAccessControl, DefaultActionlog, CurrentSettings, c.Webapps)
DefaultDalConnection = Connection(ctx, dal.Service(), c.DB)

View File

@ -8,11 +8,11 @@ import (
"time"
"github.com/cortezaproject/corteza/server/pkg/actionlog"
"github.com/cortezaproject/corteza/server/pkg/sass"
"github.com/spf13/cast"
"github.com/cortezaproject/corteza/server/pkg/errors"
"github.com/cortezaproject/corteza/server/pkg/logger"
"github.com/cortezaproject/corteza/server/pkg/options"
"github.com/cortezaproject/corteza/server/store"
"github.com/cortezaproject/corteza/server/system/types"
"go.uber.org/zap"
@ -26,6 +26,7 @@ type (
accessControl accessController
logger *zap.Logger
m sync.RWMutex
webappsConf options.WebappOpt
// Holds reference to the "current" settings that
// are used by the services
@ -49,13 +50,14 @@ type (
}
)
func Settings(ctx context.Context, s store.SettingValues, log *zap.Logger, ac accessController, al actionlog.Recorder, current interface{}) *settings {
func Settings(ctx context.Context, s store.SettingValues, log *zap.Logger, ac accessController, al actionlog.Recorder, current interface{}, webappsConf options.WebappOpt) *settings {
svc := &settings{
actionlog: al,
store: s,
accessControl: ac,
logger: log.Named("settings"),
current: current,
webappsConf: webappsConf,
listeners: make([]registeredSettingsListener, 0, 2),
update: make(chan types.SettingValueSet, 0),
}
@ -245,9 +247,16 @@ func (svc *settings) BulkSet(ctx context.Context, vv types.SettingValueSet) (err
}
for _, v := range vv {
// if branding-sass or custom-css is updated, empty stylesheet cache
if v.Name == "ui.studio.branding-sass" || v.Name == "ui.custom-css" {
sass.StylesheetCache.Set(map[string]string{})
// if any of webapps stylesheet settings is updated, we need to recompute and update affected theme css
if v.Name == "ui.studio.themes" || v.Name == "ui.studio.custom-css" {
var compStyles *types.SettingValue
if v.Name == "ui.studio.themes" {
compStyles = current.FindByName("ui.studio.custom-css")
} else {
compStyles = current.FindByName("ui.studio.themes")
}
updateCSS(v, current.FindByName(v.Name), compStyles, v.Name, svc.webappsConf.ScssDirPath, svc.logger)
}
svc.logChange(ctx, v)

View File

@ -1,10 +1,11 @@
package service
import (
"context"
"strings"
"github.com/cortezaproject/corteza/server/pkg/logger"
"github.com/cortezaproject/corteza/server/pkg/sass"
"github.com/cortezaproject/corteza/server/pkg/str"
"github.com/cortezaproject/corteza/server/system/types"
"go.uber.org/zap"
)
@ -17,48 +18,160 @@ import (
// If dart isn't installed on the host machine, customCustom css will continue to function, but without sass support.
//
// In case of an error, it will return default css and log out the error
func GenerateCSS(ctx context.Context, brandingSass, customCSS, sassDirPath string) (string, error) {
func GenerateCSS(settings *types.AppSettings, sassDirPath string) (err error) {
var (
log = logger.Default()
transpiler = sass.DartSass(log)
log = logger.Default()
studio = settings.UI.Studio
customCSSMap = make(map[string]string)
)
//check if cache has already compiled css value
if !sass.StylesheetCache.Empty() {
return sass.StylesheetCache.Get("css"), nil
for _, customCSS := range studio.CustomCSS {
customCSSMap[customCSS.ID] = customCSS.Values
}
sass.DefaultCSS(log, customCSSMap["general"])
if studio.Themes == nil && studio.CustomCSS == nil {
return
}
// if dart sass is not installed, or when the sass transpiler creation and startup process fails.
// return contents from default css
if transpiler == nil {
updateSassInstalledSetting(ctx, log, false)
return sass.DefaultCSS(log, customCSS), nil
if !studio.SassInstalled {
return
}
updateSassInstalledSetting(ctx, log, true)
transpiler := sass.DartSassTranspiler(log)
// transpile sass to css
err := sass.Transpiler(log, brandingSass, customCSS, sassDirPath, transpiler)
// transpile sass to css for each theme
if studio.Themes != nil {
for _, theme := range studio.Themes {
if studio.CustomCSS == nil {
err := sass.Transpile(transpiler, log, theme.ID, theme.Values, "", sassDirPath)
if err != nil {
continue
}
}
if err != nil {
return sass.DefaultCSS(log, customCSS), err
customCSS := processCustomCSS(theme.ID, customCSSMap)
// transpile sass to css
err := sass.Transpile(transpiler, log, theme.ID, theme.Values, customCSS, sassDirPath)
if err != nil {
continue
}
}
} else {
if studio.CustomCSS != nil {
for key := range customCSSMap {
if key == "general" {
continue
}
customCSS := processCustomCSS(key, customCSSMap)
// transpile sass to css
err := sass.Transpile(transpiler, log, key, "", customCSS, sassDirPath)
if err != nil {
continue
}
}
}
}
return sass.StylesheetCache.Get("css"), nil
return
}
func updateSassInstalledSetting(ctx context.Context, log *zap.Logger, installed bool) {
sv := &types.SettingValue{
Name: "ui.studio.sass-installed",
}
// processCustomCSS, processes CustomCSS input and gives priority to theme specific customCSS
func processCustomCSS(themeID string, customCSSMap map[string]string) (customCSS string) {
var stringsBuilder strings.Builder
err := sv.SetSetting(installed)
if err != nil {
log.Warn("failed to set ui.studio.sass-installed setting", zap.Error(err))
}
stringsBuilder.WriteString(customCSSMap["general"])
stringsBuilder.WriteString("\n")
stringsBuilder.WriteString(customCSSMap[themeID])
err = DefaultSettings.Set(ctx, sv)
if err != nil {
log.Warn("failed to set ui.studio.sass-installed setting", zap.Error(err))
}
return stringsBuilder.String()
}
// updateCSS, updates theme css when ui.studio.themes or ui.studio.custom-css settings are updated
func updateCSS(current, old, compStyles *types.SettingValue, name, sassDirPath string, log *zap.Logger) {
transpiler := sass.DartSassTranspiler(log)
complimentaryStylesMap := themeMap(compStyles)
oldThemesMap := themeMap(old)
currentThemesMap := themeMap(current)
transpileSASS := func(themeID, themeSASS string, themeCustomCSS map[string]string) {
customCSS := processCustomCSS(themeID, themeCustomCSS)
err := sass.Transpile(transpiler, log, themeID, themeSASS, customCSS, sassDirPath)
if err != nil {
log.Error("failed to transpile sass to css", zap.Error(err))
}
}
for key := range currentThemesMap {
if str.HashStringSHA256(oldThemesMap[key]) == str.HashStringSHA256(currentThemesMap[key]) {
continue
}
if name == "ui.studio.themes" {
transpileSASS(key, currentThemesMap[key], complimentaryStylesMap)
continue
}
if key == "general" {
if complimentaryStylesMap == nil {
themeID := "light"
transpileSASS(themeID, complimentaryStylesMap[themeID], currentThemesMap)
continue
}
for themeID := range complimentaryStylesMap {
transpileSASS(themeID, complimentaryStylesMap[themeID], currentThemesMap)
}
continue
}
transpileSASS(key, complimentaryStylesMap[key], currentThemesMap)
}
}
func themeMap(settingsValue *types.SettingValue) (themeMap map[string]string) {
var themes []types.Theme
if settingsValue == nil {
return themeMap
}
_ = settingsValue.Value.Unmarshal(&themes)
themeMap = make(map[string]string)
for _, theme := range themes {
themeMap[theme.ID] = theme.Values
}
return themeMap
}
func FetchCSS() string {
var stringsBuilder strings.Builder
if sass.StylesheetCache.Get("root-light") == "" {
return sass.StylesheetCache.Get("default-theme")
}
// root css section
stringsBuilder.WriteString(sass.StylesheetCache.Get("root-light"))
stringsBuilder.WriteString("\n")
stringsBuilder.WriteString(sass.StylesheetCache.Get("root-dark"))
stringsBuilder.WriteString("\n")
//theme css section
stringsBuilder.WriteString(sass.StylesheetCache.Get("theme-dark"))
stringsBuilder.WriteString("\n")
// body css section
stringsBuilder.WriteString(sass.StylesheetCache.Get("main-light"))
stringsBuilder.WriteString("\n")
return stringsBuilder.String()
}

File diff suppressed because it is too large Load Diff

View File

@ -47,6 +47,7 @@ type (
AvatarBgColor string `json:"avatarBgColor,omitempty"`
PreferredLanguage string `json:"preferredLanguage"`
Theme string `json:"theme"`
// User's security policy settings
SecurityPolicy struct {
@ -163,3 +164,13 @@ func (u *User) Clone() *User {
func (meta *UserMeta) Scan(src any) error { return sql.ParseJSON(src, meta) }
func (meta *UserMeta) Value() (driver.Value, error) { return json.Marshal(meta) }
func ParseUserMeta(ss []string) (p *UserMeta, err error) {
p = &UserMeta{}
if len(ss) == 0 {
return
}
return p, json.Unmarshal([]byte(ss[0]), p)
}