mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-08-22 02:17:17 +02:00
Added change company view color
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
@@ -16,6 +16,8 @@ type Company struct {
|
||||
UpdatedAt *time.Time `gorm:"not null;index"`
|
||||
Name string `gorm:"not null;unique;index"`
|
||||
Comment *string `gorm:"type:text"`
|
||||
// Color is an optional #RGB or #RRGGBB used to tint the company view banner and frame
|
||||
Color *string `gorm:"type:text"`
|
||||
|
||||
// backref: many-to-one
|
||||
Users []*User //`gorm:"foreignKey:CompanyID;"`
|
||||
|
||||
@@ -16,6 +16,7 @@ type Company struct {
|
||||
UpdatedAt *time.Time `json:"updatedAt"`
|
||||
Name nullable.Nullable[vo.String64] `json:"name"`
|
||||
Comment nullable.Nullable[vo.OptionalString1MB] `json:"comment"`
|
||||
Color nullable.Nullable[vo.OptionalHexColor] `json:"color"`
|
||||
}
|
||||
|
||||
// Validate checks if the Company configuration with a valid state
|
||||
@@ -43,5 +44,11 @@ func (c *Company) ToDBMap() map[string]any {
|
||||
m["comment"] = comment.String()
|
||||
}
|
||||
}
|
||||
if c.Color.IsSpecified() {
|
||||
m["color"] = nil
|
||||
if color, err := c.Color.Get(); err == nil {
|
||||
m["color"] = color.String()
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -152,11 +152,18 @@ func ToCompany(row *database.Company) *model.Company {
|
||||
if row.Comment != nil {
|
||||
comment = nullable.NewNullableWithValue(*vo.NewUnsafeOptionalString1MB(*row.Comment))
|
||||
}
|
||||
var color nullable.Nullable[vo.OptionalHexColor]
|
||||
if row.Color != nil {
|
||||
if c, err := vo.NewOptionalHexColor(*row.Color); err == nil {
|
||||
color = nullable.NewNullableWithValue(*c)
|
||||
}
|
||||
}
|
||||
return &model.Company{
|
||||
ID: id,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
Name: name,
|
||||
Comment: comment,
|
||||
Color: color,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +230,9 @@ func (s *Company) UpdateByID(
|
||||
if v, err := company.Comment.Get(); err == nil {
|
||||
current.Comment.Set(v)
|
||||
}
|
||||
if company.Color.IsSpecified() {
|
||||
current.Color = company.Color
|
||||
}
|
||||
// validate
|
||||
if err := company.Validate(); err != nil {
|
||||
s.Logger.Errorw("failed to validate company", "error", err)
|
||||
|
||||
@@ -3,6 +3,7 @@ package vo
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -177,6 +178,66 @@ func (s *OptionalString64) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// hexColorPattern matches a #RGB or #RRGGBB color
|
||||
var hexColorPattern = regexp.MustCompile(`^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`)
|
||||
|
||||
// OptionalHexColor is an empty string or a #RGB / #RRGGBB hex color.
|
||||
// it is constrained so the value is safe to render into a style attribute
|
||||
type OptionalHexColor struct {
|
||||
inner string
|
||||
}
|
||||
|
||||
// NewOptionalHexColor creates a new optional hex color
|
||||
func NewOptionalHexColor(s string) (*OptionalHexColor, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" && !hexColorPattern.MatchString(s) {
|
||||
return nil, errors.New("invalid hex color")
|
||||
}
|
||||
return &OptionalHexColor{
|
||||
inner: strings.ToLower(s),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewOptionalHexColorMust creates a new optional hex color and panics if it fails
|
||||
func NewOptionalHexColorMust(s string) *OptionalHexColor {
|
||||
a, err := NewOptionalHexColor(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// NewEmptyOptionalHexColor creates a new empty optional hex color
|
||||
func NewEmptyOptionalHexColor() *OptionalHexColor {
|
||||
return &OptionalHexColor{
|
||||
inner: "",
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the string representation of the hex color
|
||||
func (s OptionalHexColor) String() string {
|
||||
return s.inner
|
||||
}
|
||||
|
||||
// MarshalJSON implements the json.Marshaler interface
|
||||
func (s OptionalHexColor) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(s.inner)
|
||||
}
|
||||
|
||||
// UnmarshalJSON unmarshals the json into a hex color
|
||||
func (s *OptionalHexColor) UnmarshalJSON(data []byte) error {
|
||||
var str string
|
||||
if err := json.Unmarshal(data, &str); err != nil {
|
||||
return err
|
||||
}
|
||||
ss, err := NewOptionalHexColor(str)
|
||||
if err != nil {
|
||||
return unwrapError(err)
|
||||
}
|
||||
s.inner = ss.inner
|
||||
return nil
|
||||
}
|
||||
|
||||
// OptionalString127 is a trimmed string with a min of 0 and a max of 127
|
||||
type OptionalString127 struct {
|
||||
inner string
|
||||
|
||||
@@ -1243,13 +1243,18 @@ export class API {
|
||||
* @param {string} id
|
||||
* @param {string} name
|
||||
* @param {string} comment
|
||||
* @param {string} [color] #RGB or #RRGGBB, empty string clears it
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
update: async (id, name, comment) => {
|
||||
return await postJSON(this.getPath(`/company/${id}`), {
|
||||
update: async (id, name, comment, color) => {
|
||||
const body = {
|
||||
name: name,
|
||||
comment: comment
|
||||
});
|
||||
};
|
||||
if (color !== undefined) {
|
||||
body.color = color;
|
||||
}
|
||||
return await postJSON(this.getPath(`/company/${id}`), body);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<script>
|
||||
import { AppStateService } from '$lib/service/appState';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { api } from '$lib/api/apiProxy.js';
|
||||
import { showIsLoading } from '$lib/store/loading';
|
||||
import { resourceContext } from '$lib/store/resourceContext';
|
||||
import { companyColorOverride } from '$lib/store/companyColor';
|
||||
|
||||
// default banner color, matches the active-blue tailwind token
|
||||
const DEFAULT_COLOR = '#1e3fa8';
|
||||
|
||||
let context = {
|
||||
current: '',
|
||||
@@ -10,6 +15,45 @@
|
||||
companyID: null
|
||||
};
|
||||
|
||||
// custom color of the company currently being viewed, null when none
|
||||
let companyColor = null;
|
||||
// company id the color was last loaded for, avoids refetching on every update
|
||||
let loadedColorForID = null;
|
||||
|
||||
// load the company custom color so the banner and frame can be tinted
|
||||
async function loadCompanyColor(companyID) {
|
||||
loadedColorForID = companyID;
|
||||
try {
|
||||
const res = await api.company.getByID(companyID);
|
||||
companyColor = res.success && res.data?.color ? res.data.color : null;
|
||||
} catch (_) {
|
||||
companyColor = null;
|
||||
}
|
||||
}
|
||||
|
||||
// expand #rgb to #rrggbb and parse to rgb components
|
||||
function parseHex(hex) {
|
||||
let h = hex.replace('#', '');
|
||||
if (h.length === 3) {
|
||||
h = h
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('');
|
||||
}
|
||||
return {
|
||||
r: parseInt(h.slice(0, 2), 16),
|
||||
g: parseInt(h.slice(2, 4), 16),
|
||||
b: parseInt(h.slice(4, 6), 16)
|
||||
};
|
||||
}
|
||||
|
||||
// pick a readable text color for a given background using relative luminance
|
||||
function readableTextColor(hex) {
|
||||
const { r, g, b } = parseHex(hex);
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance > 0.6 ? '#111827' : '#ffffff';
|
||||
}
|
||||
|
||||
let resource = {
|
||||
resourceType: null,
|
||||
resourceCompanyID: null,
|
||||
@@ -67,6 +111,14 @@
|
||||
location.reload();
|
||||
}
|
||||
|
||||
// load the color whenever the viewed company changes, clear it in global context
|
||||
$: if (context.companyID && context.companyID !== loadedColorForID) {
|
||||
loadCompanyColor(context.companyID);
|
||||
} else if (!context.companyID) {
|
||||
companyColor = null;
|
||||
loadedColorForID = null;
|
||||
}
|
||||
|
||||
$: isCompanyView = context.current === AppStateService.CONTEXT.COMPANY && context.companyName;
|
||||
$: isResourceActive = resource.isActive;
|
||||
$: isResourceGlobal = isResourceActive && !resource.resourceCompanyID;
|
||||
@@ -80,36 +132,47 @@
|
||||
(!isCompanyView && resource.resourceCompanyID) ||
|
||||
isResourceInDifferentCompany);
|
||||
|
||||
// determine banner style and content
|
||||
$: bannerStyle = 'bg-active-blue dark:bg-active-blue';
|
||||
// a live override from company settings takes precedence over the fetched
|
||||
// color so edits show instantly and saved values do not need a page reload
|
||||
$: effectiveColor =
|
||||
$companyColorOverride && $companyColorOverride.companyID === context.companyID
|
||||
? $companyColorOverride.color
|
||||
: companyColor;
|
||||
// the effective color used for the banner and frame
|
||||
$: activeColor = effectiveColor || DEFAULT_COLOR;
|
||||
// foreground used for banner text so it stays readable on any color
|
||||
$: bannerForeground = readableTextColor(activeColor);
|
||||
// inline styles so a custom company color can override the default
|
||||
$: bannerStyle = `background-color: ${activeColor}; color: ${bannerForeground};`;
|
||||
$: frameStyle = `border-color: ${activeColor};`;
|
||||
</script>
|
||||
|
||||
{#if isCompanyView || hasContextMismatch}
|
||||
<!-- top banner -->
|
||||
<div class="w-full h-9 {bannerStyle} z-30 company-banner">
|
||||
<div class="w-full h-9 z-30 company-banner" style={bannerStyle}>
|
||||
<div class="h-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-center gap-4 h-full">
|
||||
{#if hasContextMismatch}
|
||||
<!-- context mismatch view -->
|
||||
<div class="flex items-center space-x-2">
|
||||
{#if isCompanyView && isResourceGlobal}
|
||||
<span class="text-white/70 font-medium text-sm">Viewing as</span>
|
||||
<span class="text-white font-semibold text-sm">{context.companyName}</span>
|
||||
<span class="text-white/70 font-medium text-sm">•</span>
|
||||
<span class="text-white/90 font-medium text-sm">
|
||||
<span class="opacity-70 font-medium text-sm">Viewing as</span>
|
||||
<span class="font-semibold text-sm">{context.companyName}</span>
|
||||
<span class="opacity-70 font-medium text-sm">•</span>
|
||||
<span class="opacity-90 font-medium text-sm">
|
||||
This {resource.resourceType || 'resource'} is
|
||||
<strong class="font-bold">global</strong>
|
||||
</span>
|
||||
{:else if !isCompanyView && resource.resourceCompanyID}
|
||||
<span class="text-white/90 font-medium text-sm">
|
||||
<span class="opacity-90 font-medium text-sm">
|
||||
This {resource.resourceType || 'resource'} belongs to
|
||||
<strong class="font-bold">{resource.resourceCompanyName || 'a company'}</strong>
|
||||
</span>
|
||||
{:else if isResourceInDifferentCompany}
|
||||
<span class="text-white/70 font-medium text-sm">Viewing as</span>
|
||||
<span class="text-white font-semibold text-sm">{context.companyName}</span>
|
||||
<span class="text-white/70 font-medium text-sm">•</span>
|
||||
<span class="text-white/90 font-medium text-sm">
|
||||
<span class="opacity-70 font-medium text-sm">Viewing as</span>
|
||||
<span class="font-semibold text-sm">{context.companyName}</span>
|
||||
<span class="opacity-70 font-medium text-sm">•</span>
|
||||
<span class="opacity-90 font-medium text-sm">
|
||||
This {resource.resourceType || 'resource'} belongs to
|
||||
<strong class="font-bold"
|
||||
>{resource.resourceCompanyName || 'another company'}</strong
|
||||
@@ -120,7 +183,7 @@
|
||||
<!-- switch button -->
|
||||
<button
|
||||
on:click={switchToResourceContext}
|
||||
class="flex items-center gap-1.5 px-3 py-1 bg-white/20 hover:bg-white/30 text-white rounded text-xs font-semibold transition-colors duration-200"
|
||||
class="flex items-center gap-1.5 px-3 py-1 bg-white/20 hover:bg-white/30 rounded text-xs font-semibold transition-colors duration-200"
|
||||
title="Switch to {isResourceGlobal
|
||||
? 'global'
|
||||
: resource.resourceCompanyName || 'company'} context"
|
||||
@@ -139,8 +202,8 @@
|
||||
{:else}
|
||||
<!-- normal company view -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-white/70 font-medium text-sm">Viewing as</span>
|
||||
<span class="text-white font-semibold text-sm">
|
||||
<span class="opacity-70 font-medium text-sm">Viewing as</span>
|
||||
<span class="font-semibold text-sm">
|
||||
{context.companyName}
|
||||
</span>
|
||||
</div>
|
||||
@@ -148,7 +211,7 @@
|
||||
<!-- exit button -->
|
||||
<button
|
||||
on:click={exitCompanyView}
|
||||
class="flex items-center gap-1 px-2 py-0.5 text-white/50 hover:text-white/80 text-xs transition-colors duration-200"
|
||||
class="flex items-center gap-1 px-2 py-0.5 opacity-50 hover:opacity-80 text-xs transition-opacity duration-200"
|
||||
title="Exit company view"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -168,7 +231,7 @@
|
||||
|
||||
<!-- border frame around entire viewport when in company view or context mismatch -->
|
||||
{#if isCompanyView || hasContextMismatch}
|
||||
<div class="company-view-frame"></div>
|
||||
<div class="company-view-frame" style={frameStyle}></div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@@ -183,11 +246,8 @@
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border: 3px solid;
|
||||
border-color: var(--company-frame-color, #1e3fa8);
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
:global(.dark) .company-view-frame {
|
||||
border-color: #1e3fa8;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
// live override for the company banner color so changes made in company
|
||||
// settings show in the banner and frame without a page reload
|
||||
// shape: { companyID: string, color: string } or null
|
||||
export const companyColorOverride = writable(null);
|
||||
@@ -1,10 +1,11 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { api } from '$lib/api/apiProxy.js';
|
||||
import { addToast } from '$lib/store/toast';
|
||||
import { showIsLoading, hideIsLoading } from '$lib/store/loading.js';
|
||||
import { companyColorOverride } from '$lib/store/companyColor';
|
||||
import HeadTitle from '$lib/components/HeadTitle.svelte';
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte';
|
||||
import SettingsLoading from '$lib/components/SettingsLoading.svelte';
|
||||
@@ -25,13 +26,34 @@
|
||||
let loaded = false;
|
||||
let company = null;
|
||||
|
||||
// default banner color used when a company has no custom color
|
||||
const DEFAULT_COMPANY_COLOR = '#1e3fa8';
|
||||
|
||||
// general form
|
||||
let formValues = {
|
||||
name: '',
|
||||
comment: ''
|
||||
comment: '',
|
||||
color: ''
|
||||
};
|
||||
let generalError = '';
|
||||
let isSaving = false;
|
||||
// the persisted color, used to revert an unsaved live preview on leave
|
||||
let savedColor = '';
|
||||
|
||||
// push the chosen color to the banner store for a live preview while editing
|
||||
// only once loaded so the banner keeps its own value until we have the form
|
||||
$: if (loaded) {
|
||||
companyColorOverride.set({ companyID: companyId, color: formValues.color });
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
// drop any unsaved preview, leaving the banner on the persisted color
|
||||
if (loaded) {
|
||||
companyColorOverride.set({ companyID: companyId, color: savedColor });
|
||||
} else {
|
||||
companyColorOverride.set(null);
|
||||
}
|
||||
});
|
||||
|
||||
// auto-prune (saved on change, like display mode in settings)
|
||||
let autoPruneEnabled = false;
|
||||
@@ -100,6 +122,8 @@
|
||||
company = res.data;
|
||||
formValues.name = company.name || '';
|
||||
formValues.comment = company.comment || '';
|
||||
formValues.color = company.color || '';
|
||||
savedColor = formValues.color;
|
||||
} catch (e) {
|
||||
addToast('Failed to get company', 'Error');
|
||||
console.error('failed to get company', e);
|
||||
@@ -132,7 +156,12 @@
|
||||
generalError = '';
|
||||
isSaving = true;
|
||||
try {
|
||||
const res = await api.company.update(companyId, formValues.name, formValues.comment);
|
||||
const res = await api.company.update(
|
||||
companyId,
|
||||
formValues.name,
|
||||
formValues.comment,
|
||||
formValues.color
|
||||
);
|
||||
if (!res.success) {
|
||||
generalError = res.error;
|
||||
return;
|
||||
@@ -247,6 +276,38 @@
|
||||
class="w-full p-3 rounded-md text-gray-600 dark:text-gray-300 border border-transparent dark:border-gray-700/60 bg-grayblue-light dark:bg-gray-900/60 focus:outline-none focus:border-slate-400 dark:focus:border-highlight-blue/80 focus:bg-gray-100 dark:focus:bg-gray-700/60 resize-y transition-colors duration-200"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col py-2">
|
||||
<p class="font-semibold text-slate-600 dark:text-gray-400 py-2">Banner Color</p>
|
||||
<p class="text-gray-600 dark:text-gray-300 text-sm mb-3">
|
||||
Tints the banner and frame shown while viewing as this company, making it easier to
|
||||
recognize which company you are working in.
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
aria-label="Company banner color"
|
||||
value={formValues.color || DEFAULT_COMPANY_COLOR}
|
||||
on:input={(e) => (formValues.color = e.currentTarget.value)}
|
||||
class="h-9 w-12 rounded-md border border-gray-300 dark:border-gray-700 bg-transparent cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={formValues.color}
|
||||
placeholder={DEFAULT_COMPANY_COLOR}
|
||||
maxlength="7"
|
||||
class="w-32 p-2 rounded-md text-gray-600 dark:text-gray-300 border border-transparent dark:border-gray-700/60 bg-grayblue-light dark:bg-gray-900/60 focus:outline-none focus:border-slate-400 dark:focus:border-highlight-blue/80 transition-colors duration-200"
|
||||
/>
|
||||
{#if formValues.color}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (formValues.color = '')}
|
||||
class="text-sm text-gray-500 dark:text-gray-400 hover:text-cta-blue dark:hover:text-highlight-blue transition-colors"
|
||||
>
|
||||
Reset to default
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<FormError message={generalError} />
|
||||
<div class="mt-6 flex justify-end">
|
||||
<FormButton size="medium" isSubmitting={isSaving}>Save Changes</FormButton>
|
||||
|
||||
Reference in New Issue
Block a user