feat(frontend): allow workspace admin to set workspace color (#5032)

* feat(frontend): allow workspace admin to set workspace color

* sqlx prep

* sqlx prep

* make border wider, add removed comment
This commit is contained in:
Alexander Petric
2025-01-09 00:42:36 -05:00
committed by GitHub
parent 582aacae71
commit 050503c480
18 changed files with 453 additions and 38 deletions
@@ -122,6 +122,11 @@
"ordinal": 23,
"name": "mute_critical_alerts",
"type_info": "Bool"
},
{
"ordinal": 24,
"name": "color",
"type_info": "Varchar"
}
],
"parameters": {
@@ -153,6 +158,7 @@
false,
true,
true,
true,
true
]
},
@@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace.id, workspace.name, workspace.owner, workspace.deleted, workspace.premium, workspace_settings.color\n FROM workspace\n LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id\n JOIN usr ON usr.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "owner",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "deleted",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "premium",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "color",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
true
]
},
"hash": "3651ed42be75d41ab0387f1551012d72c90235bb40d2f66e6fb235c990d78352"
}
@@ -122,6 +122,11 @@
"ordinal": 23,
"name": "mute_critical_alerts",
"type_info": "Bool"
},
{
"ordinal": 24,
"name": "color",
"type_info": "Varchar"
}
],
"parameters": {
@@ -153,6 +158,7 @@
false,
true,
true,
true,
true
]
},
@@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color\n FROM workspace\n JOIN usr ON usr.workspace_id = workspace.id\n JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "color",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
true
]
},
"hash": "6c845f168b6265e6cf92e4d33c5409edfdc1847d0348df2ea55504ddaaa67736"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET color = $1 WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "d2668c2d4ece82a3617f098fe993d3e218fa224bd0bb311e7db776c7fd69cf0b"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_settings\n (workspace_id, color)\n VALUES ($1, $2)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "e54eb583f011cea6e4d533f4d2014cbf509b5afcc2647048c9bfc839d8f290ad"
}
@@ -0,0 +1,53 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace.id, workspace.name, workspace.owner, workspace.deleted, workspace.premium, workspace_settings.color\n FROM workspace\n LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id\n LIMIT $1 OFFSET $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "owner",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "deleted",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "premium",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "color",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
true
]
},
"hash": "eed16e356f3f36183c3db13fcc1950295e0d0fbdabb38434534fb3430eeddc25"
}
@@ -0,0 +1 @@
ALTER TABLE workspace_settings DROP COLUMN color;
@@ -0,0 +1 @@
ALTER TABLE workspace_settings ADD COLUMN color VARCHAR(7) DEFAULT NULL;
+33
View File
@@ -1576,6 +1576,30 @@ paths:
schema:
type: string
/w/{workspace}/workspaces/change_workspace_color:
post:
summary: change workspace id
operationId: changeWorkspaceColor
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
content:
application/json:
schema:
type: object
properties:
color:
type: string
responses:
"200":
description: status
content:
text/plain:
schema:
type: string
/w/{workspace}/users/whois/{username}:
get:
summary: whois
@@ -1722,6 +1746,8 @@ paths:
$ref: "#/components/schemas/WorkspaceDefaultScripts"
mute_critical_alerts:
type: boolean
color:
type: string
required:
- code_completion_enabled
- automatic_billing
@@ -12765,10 +12791,13 @@ components:
type: string
username:
type: string
color:
type: string
required:
- id
- name
- username
- color
required:
- email
- workspaces
@@ -12782,6 +12811,8 @@ components:
type: string
username:
type: string
color:
type: string
required:
- id
- name
@@ -12797,6 +12828,8 @@ components:
type: string
domain:
type: string
color:
type: string
required:
- id
- name
+54 -9
View File
@@ -106,6 +106,7 @@ pub fn workspaced_service() -> Router {
.route("/leave", post(leave_workspace))
.route("/get_workspace_name", get(get_workspace_name))
.route("/change_workspace_name", post(change_workspace_name))
.route("/change_workspace_color", post(change_workspace_color))
.route(
"/change_workspace_id",
post(crate::workspaces_extra::change_workspace_id),
@@ -158,6 +159,7 @@ struct Workspace {
owner: String,
deleted: bool,
premium: bool,
color: Option<String>,
}
#[derive(FromRow, Serialize, Debug)]
@@ -186,6 +188,7 @@ pub struct WorkspaceSettings {
pub automatic_billing: bool,
pub default_scripts: Option<serde_json::Value>,
pub mute_critical_alerts: Option<bool>,
pub color: Option<String>,
}
#[derive(FromRow, Serialize, Debug)]
@@ -263,6 +266,7 @@ struct CreateWorkspace {
id: String,
name: String,
username: Option<String>,
color: Option<String>,
}
#[derive(Deserialize)]
@@ -282,6 +286,7 @@ struct UserWorkspace {
pub id: String,
pub name: String,
pub username: String,
pub color: Option<String>,
}
#[derive(Deserialize)]
@@ -376,8 +381,11 @@ async fn list_workspaces(
let mut tx = user_db.begin(&authed).await?;
let workspaces = sqlx::query_as!(
Workspace,
"SELECT workspace.* FROM workspace, usr WHERE usr.workspace_id = workspace.id AND \
usr.email = $1 AND deleted = false",
"SELECT workspace.id, workspace.name, workspace.owner, workspace.deleted, workspace.premium, workspace_settings.color
FROM workspace
LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id
JOIN usr ON usr.workspace_id = workspace.id
WHERE usr.email = $1 AND workspace.deleted = false",
authed.email
)
.fetch_all(&mut *tx)
@@ -1331,7 +1339,10 @@ async fn list_workspaces_as_super_admin(
let mut tx = user_db.begin(&authed).await?;
let workspaces = sqlx::query_as!(
Workspace,
"SELECT * FROM workspace LIMIT $1 OFFSET $2",
"SELECT workspace.id, workspace.name, workspace.owner, workspace.deleted, workspace.premium, workspace_settings.color
FROM workspace
LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id
LIMIT $1 OFFSET $2",
per_page as i32,
offset as i32
)
@@ -1348,9 +1359,11 @@ async fn user_workspaces(
let mut tx = db.begin().await?;
let workspaces = sqlx::query_as!(
UserWorkspace,
"SELECT workspace.id, workspace.name, usr.username
FROM workspace, usr WHERE usr.workspace_id = workspace.id AND usr.email = $1 AND deleted = \
false",
"SELECT workspace.id, workspace.name, usr.username, workspace_settings.color
FROM workspace
JOIN usr ON usr.workspace_id = workspace.id
JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id
WHERE usr.email = $1 AND workspace.deleted = false",
email
)
.fetch_all(&mut *tx)
@@ -1430,9 +1443,10 @@ async fn create_workspace(
.await?;
sqlx::query!(
"INSERT INTO workspace_settings
(workspace_id)
VALUES ($1)",
nw.id
(workspace_id, color)
VALUES ($1, $2)",
nw.id,
nw.color,
)
.execute(&mut *tx)
.await?;
@@ -1943,6 +1957,11 @@ struct ChangeWorkspaceName {
new_name: String,
}
#[derive(Deserialize)]
struct ChangeWorkspaceColor {
color: Option<String>,
}
async fn change_workspace_name(
authed: ApiAuthed,
Path(w_id): Path<String>,
@@ -1977,6 +1996,32 @@ async fn change_workspace_name(
Ok(format!("updated workspace name to {}", &rw.new_name))
}
async fn change_workspace_color(
authed: ApiAuthed,
Path(w_id): Path<String>,
Extension(db): Extension<DB>,
Json(rw): Json<ChangeWorkspaceColor>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
let mut tx = db.begin().await?;
sqlx::query!(
"UPDATE workspace_settings SET color = $1 WHERE workspace_id = $2",
rw.color,
&w_id
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(format!(
"updated workspace color to {}",
rw.color.as_deref().unwrap_or("no color")
))
}
async fn get_usage(Extension(db): Extension<DB>, Path(w_id): Path<String>) -> Result<String> {
let usage = sqlx::query_scalar!(
"
@@ -0,0 +1,96 @@
<script lang="ts">
import { workspaceStore, usersWorkspaceStore } from '$lib/stores'
import Button from '../common/button/Button.svelte'
import { sendUserToast } from '$lib/toast'
import { WorkspaceService } from '$lib/gen'
import Modal from '../common/modal/Modal.svelte'
import { Pen } from 'lucide-svelte'
import Toggle from '$lib/components/Toggle.svelte'
let colorEnabled = false
export let open = false
$: workspaceColor = $usersWorkspaceStore?.workspaces.find(w => w.id === $workspaceStore)?.color
$: colorEnabled = !!workspaceColor
$: colorEnabled && !workspaceColor && generateRandomColor()
function generateRandomColor() {
const randomColor =
'#' +
Math.floor(Math.random() * 16777215)
.toString(16)
.padStart(6, '0')
workspaceColor = randomColor
}
async function changeWorkspaceColor() {
const colorToSave = colorEnabled && workspaceColor ? workspaceColor : undefined
open = false
await WorkspaceService.changeWorkspaceColor({
workspace: $workspaceStore!,
requestBody: {
color: colorToSave
}
})
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
sendUserToast(`Workspace color updated.`)
}
</script>
<div>
<p class="font-semibold text-sm">Workspace Color</p>
<div class="flex flex-row gap-0.5 items-center">
{#if workspaceColor}
<div
class="w-5 h-5 rounded-full border border-gray-300 dark:border-gray-600"
style="background-color: {workspaceColor}"
/>
{:else}
<span class="text-xs text-secondary">No color set</span>
{/if}
<Button
on:click={() => {
open = true
}}
size="xs"
spacingSize="xs2"
color="light"
iconOnly
startIcon={{
icon: Pen
}}
/>
</div>
</div>
<Modal bind:open title="Change Workspace Color">
<div class="flex flex-col gap-4">
<label class="block">
<span class="text-secondary text-sm">Workspace color</span>
<div class="flex items-center gap-2">
<Toggle bind:checked={colorEnabled} options={{ right: 'Enable' }} />
<input class="w-10" type="color" bind:value={workspaceColor} disabled={!colorEnabled} />
<input
type="text"
class="w-24 text-sm"
bind:value={workspaceColor}
disabled={!colorEnabled}
/>
<Button on:click={generateRandomColor} size="xs" disabled={!colorEnabled}>Random</Button>
</div>
</label>
</div>
<svelte:fragment slot="actions">
<Button
size="sm"
on:click={() => {
changeWorkspaceColor()
}}
>
Save
</Button>
</svelte:fragment>
</Modal>
@@ -11,7 +11,7 @@
export let stopPropagationOnClick: boolean = false
export let shortcut: string = ''
export let notificationsCount: number = 0
export let color: string | null = null
let dispatch = createEventDispatcher()
</script>
@@ -26,10 +26,12 @@
'group flex items-center px-2 py-2 font-light rounded-md h-8 gap-3 w-full',
lightMode
? 'text-primary hover:bg-surface-hover '
: ' hover:bg-[#2A3648] text-primary-inverse dark:text-primary',
: 'hover:bg-[#2A3648] text-primary-inverse dark:text-primary',
color ? 'border-4' : '',
'transition-all',
$$props.class
)}
style={color ? `border-color: ${color}; padding: 0 calc(0.5rem - 4px);` : ''}
title={label}
>
{#if icon}
@@ -50,7 +52,7 @@
<span
class={twMerge(
'whitespace-pre truncate',
lightMode ? 'text-primary' : 'text-primary-inverse dark:text-primary',
lightMode ? 'text-primary' : 'text-primary-inverse dark:text-primary',
'transition-all',
$$props.class
)}
@@ -20,10 +20,11 @@
import { MenuItem } from '@rgossiaux/svelte-headlessui'
import { isCloudHosted } from '$lib/cloud'
import { initAllAiWorkspace } from '../copilot/lib'
import { twMerge } from 'tailwind-merge'
export let isCollapsed: boolean = false
// When used outside of the side bar, where links to workspace settings and such don't make as much sense.
// When used outside of the side bar, where links to workspace settings and such don't make as much sense.
export let strictWorkspaceSelect = false
async function toggleSwitchWorkspace(id: string) {
@@ -55,7 +56,13 @@
<Menu>
<div slot="trigger">
<MenuButton class="!text-xs" icon={Building} label={$workspaceStore ?? ''} {isCollapsed} />
<MenuButton
class="!text-xs"
icon={Building}
label={$workspaceStore ?? ''}
{isCollapsed}
color={$userWorkspaces.find((w) => w.id === $workspaceStore)?.color}
/>
</div>
<div class="divide-y" role="none">
@@ -63,17 +70,29 @@
{#each $userWorkspaces as workspace}
<MenuItem>
<button
class="text-xs min-w-0 w-full overflow-hidden flex flex-col py-1.5
{$workspaceStore === workspace.id
? 'cursor-default bg-surface-selected'
: 'cursor-pointer hover:bg-surface-hover'}"
class={twMerge(
'text-xs min-w-0 w-full overflow-hidden flex flex-col py-1.5',
$workspaceStore === workspace.id
? 'cursor-default bg-surface-selected'
: 'cursor-pointer hover:bg-surface-hover'
)}
on:click={async () => {
await toggleSwitchWorkspace(workspace.id)
}}
>
<div class="text-primary pl-4 truncate text-left text-[1.2em]">{workspace.name}</div>
<div class="text-tertiary font-mono pl-4 text-2xs whitespace-nowrap truncate text-left">
{workspace.id}
<div class="flex items-center justify-between">
<div>
<div class="text-primary pl-4 truncate text-left text-[1.2em]">{workspace.name}</div>
<div class="text-tertiary font-mono pl-4 text-2xs whitespace-nowrap truncate text-left">
{workspace.id}
</div>
</div>
{#if workspace.color}
<div
class="w-5 h-5 mr-2 rounded border border-gray-300 dark:border-gray-600"
style="background-color: {workspace.color}"
></div>
{/if}
</div>
</button>
</MenuItem>
@@ -93,19 +112,19 @@
</div>
{/if}
{#if !strictWorkspaceSelect}
<div class="py-1" role="none">
<a
href="{base}/user/workspaces"
on:click={() => {
localStorage.removeItem('workspace')
}}
class="text-primary block px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary"
role="menuitem"
tabindex="-1"
>
All workspaces
</a>
</div>
<div class="py-1" role="none">
<a
href="{base}/user/workspaces"
on:click={() => {
localStorage.removeItem('workspace')
}}
class="text-primary block px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary"
role="menuitem"
tabindex="-1"
>
All workspaces
</a>
</div>
{/if}
{#if ($userStore?.is_admin || $superadmin) && !strictWorkspaceSelect}
<div class="py-1" role="none">
+3 -1
View File
@@ -55,6 +55,7 @@ export const userWorkspaces: Readable<
id: string
name: string
username: string
color: string | null
}>
> = derived([usersWorkspaceStore, superadmin], ([store, superadmin]) => {
const originalWorkspaces = store?.workspaces ?? []
@@ -64,7 +65,8 @@ export const userWorkspaces: Readable<
{
id: 'admins',
name: 'Admins',
username: 'superadmin'
username: 'superadmin',
color: null
}
]
} else {
@@ -37,10 +37,19 @@
let codeCompletionEnabled = true
let checking = false
let workspaceColor: string | null = null
let colorEnabled = false
function generateRandomColor() {
const randomColor = '#' + Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0');
workspaceColor = randomColor;
}
$: id = name.toLowerCase().replace(/\s/gi, '-')
$: validateName(id)
$: errorUser = validateUsername(username)
$: colorEnabled && !workspaceColor && generateRandomColor()
async function validateName(id: string): Promise<void> {
checking = true
@@ -60,6 +69,7 @@
requestBody: {
id,
name,
color: colorEnabled && workspaceColor ? workspaceColor : undefined,
username: automateUsernameCreation ? undefined : username
}
})
@@ -186,6 +196,16 @@
{/if}
<input type="text" bind:value={id} class:input-error={errorId != ''} />
</label>
<label class="block pb-4">
<span class="text-secondary text-sm">Workspace color</span>
<span class="ml-5 text-tertiary text-xs">Color to identify the current workspace</span>
<div class="flex items-center gap-2">
<Toggle bind:checked={colorEnabled} options={{ right: 'Enable' }} />
{#if colorEnabled}<input class="w-10" type="color" bind:value={workspaceColor} disabled={!colorEnabled} />{/if}
<input type="text" class="w-24 text-sm" bind:value={workspaceColor} disabled={!colorEnabled} />
<Button on:click={generateRandomColor} size="xs" disabled={!colorEnabled}>Random</Button>
</div>
</label>
{#if !automateUsernameCreation}
<label class="block pb-4">
<span class="text-secondary text-sm">Your username in that workspace</span>
@@ -28,7 +28,7 @@
let invites: WorkspaceInvite[] = []
let list_all_as_super_admin: boolean = false
let workspaces: { id: string; name: string; username: string }[] | undefined = undefined
let workspaces: { id: string; name: string; username: string; color?: string | null }[] | undefined = undefined
let userSettings: UserSettings
let superadminSettings: SuperadminSettings
@@ -190,11 +190,18 @@
<label class="block pb-2">
<button
class="block w-full mx-auto py-1 px-2 rounded-md border
shadow-sm text-sm font-normal mt-1 hover:ring-1 hover:ring-indigo-300"
shadow-sm text-sm font-normal mt-1 hover:ring-1 hover:ring-indigo-300"
on:click={async () => {
speakFriendAndEnterWorkspace(workspace.id)
}}
><span class="font-mono">{workspace.id}</span> - {workspace.name} as
>
{#if workspace.color}
<span
class="inline-block w-3 h-3 mr-2 rounded-full border border-gray-400"
style="background-color: {workspace.color}"
></span>
{/if}
<span class="font-mono">{workspace.id}</span> - {workspace.name} as
<span class="font-mono">{workspace.username}</span>
{#if workspace['deleted']}
<span class="text-red-500"> (archived)</span>
@@ -56,6 +56,7 @@
import { fade } from 'svelte/transition'
import ChangeWorkspaceName from '$lib/components/settings/ChangeWorkspaceName.svelte'
import ChangeWorkspaceId from '$lib/components/settings/ChangeWorkspaceId.svelte'
import ChangeWorkspaceColor from '$lib/components/settings/ChangeWorkspaceColor.svelte'
import {
convertBackendSettingsToFrontendSettings,
convertFrontendToBackendSetting,
@@ -818,6 +819,7 @@
<div class="flex flex-col gap-10">
<ChangeWorkspaceName />
<ChangeWorkspaceId />
<ChangeWorkspaceColor />
</div>
<PageHeader title="Export workspace" primary={false} />