feat: update workspace settings

This commit is contained in:
2026-04-30 02:03:42 -04:00
parent 6177eec2bf
commit 63738ad027
20 changed files with 7168 additions and 54 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
<script setup>
import { computed } from 'vue';
import { getTimeZoneOptions } from '@/features/workspaces/timeZones.js';
const props = defineProps({
modelValue: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue']);
const timeZoneOptions = computed(() => getTimeZoneOptions(props.modelValue));
function updateValue(event) {
emit('update:modelValue', event.target.value);
}
</script>
<template>
<select
class="time-zone-select"
:value="modelValue"
:disabled="disabled"
@change="updateValue"
>
<option
v-for="timeZone in timeZoneOptions"
:key="timeZone.value"
:value="timeZone.value"
>
{{ timeZone.label }}
</option>
</select>
</template>
<style scoped>
.time-zone-select {
@apply rounded-[1rem] border px-4 py-3 text-sm;
background: #fffdf8;
border-color: rgba(23, 32, 51, 0.1);
color: #172033;
outline: none;
}
</style>

View File

@@ -11,6 +11,8 @@ export const useWorkspaceStore = defineStore('workspace', () => {
const activeWorkspaceId = ref(null);
const isLoading = ref(false);
const isCreating = ref(false);
const isUpdating = ref(false);
const isUploadingLogo = ref(false);
const invitesByWorkspace = ref({});
const membersByWorkspace = ref({});
const isInvitesLoading = ref(false);
@@ -90,6 +92,74 @@ export const useWorkspaceStore = defineStore('workspace', () => {
}
}
async function updateWorkspace(workspaceId, payload) {
if (!authStore.isAuthenticated || !workspaceId) {
throw new Error('You must be authenticated to update a workspace.');
}
if (isUpdating.value) {
throw new Error('A workspace update request is already in progress.');
}
isUpdating.value = true;
error.value = null;
try {
const response = await client.put(`/api/workspaces/${workspaceId}`, payload);
if (response.data) {
workspaces.value = workspaces.value
.map(workspace => (workspace.id === workspaceId ? response.data : workspace))
.sort((left, right) => left.name.localeCompare(right.name));
}
return response.data;
} catch (updateError) {
console.error('Failed to update workspace:', updateError);
error.value = 'Failed to update workspace.';
throw updateError;
} finally {
isUpdating.value = false;
}
}
async function uploadWorkspaceLogo(workspaceId, file) {
if (!authStore.isAuthenticated || !workspaceId) {
throw new Error('You must be authenticated to upload a workspace logo.');
}
if (isUploadingLogo.value) {
throw new Error('A workspace logo upload is already in progress.');
}
isUploadingLogo.value = true;
error.value = null;
try {
const formData = new FormData();
formData.append('file', file, file.name || 'workspace-logo.png');
const response = await client.post(`/api/workspaces/${workspaceId}/logo`, formData);
const blobUrl = response.data?.blobUrl;
if (blobUrl) {
workspaces.value = workspaces.value.map(workspace =>
workspace.id === workspaceId
? { ...workspace, logoUrl: `${blobUrl}?${Date.now()}` }
: workspace
);
}
return response.data;
} catch (uploadError) {
console.error('Failed to upload workspace logo:', uploadError);
error.value = 'Failed to upload workspace logo.';
throw uploadError;
} finally {
isUploadingLogo.value = false;
}
}
function setActiveWorkspace(workspaceId) {
if (workspaces.value.some(workspace => workspace.id === workspaceId)) {
activeWorkspaceId.value = workspaceId;
@@ -192,6 +262,8 @@ export const useWorkspaceStore = defineStore('workspace', () => {
activeWorkspace,
isLoading,
isCreating,
isUpdating,
isUploadingLogo,
invitesByWorkspace,
membersByWorkspace,
isInvitesLoading,
@@ -200,6 +272,8 @@ export const useWorkspaceStore = defineStore('workspace', () => {
error,
fetchWorkspaces,
createWorkspace,
updateWorkspace,
uploadWorkspaceLogo,
fetchInvites,
fetchMembers,
inviteMember,

View File

@@ -0,0 +1,84 @@
const FALLBACK_TIME_ZONES = [
'UTC',
'America/Los_Angeles',
'America/Denver',
'America/Chicago',
'America/New_York',
'America/Toronto',
'America/Montreal',
'America/Vancouver',
'America/Mexico_City',
'America/Sao_Paulo',
'Europe/London',
'Europe/Paris',
'Europe/Berlin',
'Europe/Madrid',
'Europe/Rome',
'Europe/Amsterdam',
'Europe/Zurich',
'Europe/Stockholm',
'Europe/Warsaw',
'Africa/Casablanca',
'Africa/Johannesburg',
'Asia/Dubai',
'Asia/Kolkata',
'Asia/Singapore',
'Asia/Tokyo',
'Asia/Seoul',
'Asia/Shanghai',
'Australia/Sydney',
'Pacific/Auckland',
];
export function getTimeZoneOptions(selectedTimeZone) {
const supportedTimeZones = getSupportedTimeZones();
const timeZones = new Set(['UTC', ...supportedTimeZones]);
if (selectedTimeZone) {
timeZones.add(selectedTimeZone);
}
return [...timeZones]
.sort((left, right) => left.localeCompare(right))
.map(timeZone => ({
value: timeZone,
label: formatTimeZoneLabel(timeZone),
}));
}
function getSupportedTimeZones() {
if (typeof Intl.supportedValuesOf === 'function') {
return Intl.supportedValuesOf('timeZone');
}
return FALLBACK_TIME_ZONES;
}
function formatTimeZoneLabel(timeZone) {
const offset = formatTimeZoneOffset(timeZone);
if (!offset) {
return timeZone.replaceAll('_', ' ');
}
return `${timeZone.replaceAll('_', ' ')} (${offset})`;
}
function formatTimeZoneOffset(timeZone) {
try {
const parts = new Intl.DateTimeFormat('en-US', {
hour: '2-digit',
timeZone,
timeZoneName: 'shortOffset',
}).formatToParts(new Date());
const offset = parts.find(part => part.type === 'timeZoneName')?.value;
if (!offset) {
return null;
}
return offset.replace('GMT', 'UTC');
} catch {
return null;
}
}

View File

@@ -2,6 +2,7 @@
import { computed, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import TimeZoneSelect from '@/features/workspaces/components/TimeZoneSelect.vue';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
const router = useRouter();
@@ -126,9 +127,8 @@
<label class="field">
<span>{{ t('workspaceCreate.fields.timeZone') }}</span>
<input
<TimeZoneSelect
v-model="form.timeZone"
type="text"
:disabled="workspaceStore.isCreating"
/>
</label>

View File

@@ -1,6 +1,9 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import AppAvatar from '@/components/AppAvatar.vue';
import ImageCropperDialog from '@/components/ImageCropperDialog.vue';
import TimeZoneSelect from '@/features/workspaces/components/TimeZoneSelect.vue';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
import {
mdiAccountGroupOutline,
@@ -14,6 +17,15 @@
const { t } = useI18n();
const workspaceStore = useWorkspaceStore();
const activeTab = ref('general');
const settingsForm = reactive({
name: '',
timeZone: '',
});
const settingsError = ref(null);
const settingsStatus = ref(null);
const logoError = ref(null);
const logoStatus = ref(null);
const isLogoDialogOpen = ref(false);
const inviteForm = reactive({
email: '',
@@ -26,6 +38,15 @@
const workspaceMembers = computed(() =>
workspaceStore.membersByWorkspace[workspaceStore.activeWorkspaceId] ?? []
);
const isSettingsDirty = computed(() => {
const workspace = workspaceStore.activeWorkspace;
if (!workspace) {
return false;
}
return settingsForm.name.trim() !== workspace.name || settingsForm.timeZone.trim() !== workspace.timeZone;
});
const settingsTabs = computed(() => [
{ key: 'general', label: t('workspaceSettings.tabs.general'), icon: mdiCogOutline },
{ key: 'members', label: t('workspaceSettings.tabs.members'), icon: mdiAccountGroupOutline },
@@ -50,6 +71,17 @@
},
]);
watch(
() => workspaceStore.activeWorkspace,
workspace => {
settingsForm.name = workspace?.name ?? '';
settingsForm.timeZone = workspace?.timeZone ?? '';
settingsError.value = null;
settingsStatus.value = null;
},
{ immediate: true }
);
watch(
() => workspaceStore.activeWorkspaceId,
async workspaceId => {
@@ -67,6 +99,56 @@
{ immediate: true }
);
async function submitWorkspaceSettings() {
const workspace = workspaceStore.activeWorkspace;
if (!workspace || workspaceStore.isUpdating) {
return;
}
settingsError.value = null;
settingsStatus.value = null;
const name = settingsForm.name.trim();
const timeZone = settingsForm.timeZone.trim();
if (!name || !timeZone) {
settingsError.value = t('workspaceSettings.errors.required');
return;
}
try {
await workspaceStore.updateWorkspace(workspace.id, {
name,
timeZone,
});
settingsStatus.value = t('workspaceSettings.general.saved');
} catch (error) {
console.error('Failed to update workspace settings:', error);
settingsError.value = t('workspaceSettings.errors.updateFailed');
}
}
async function saveWorkspaceLogo(result) {
const workspace = workspaceStore.activeWorkspace;
if (!workspace || workspaceStore.isUploadingLogo) {
return;
}
logoError.value = null;
logoStatus.value = null;
try {
await workspaceStore.uploadWorkspaceLogo(workspace.id, result.file);
logoStatus.value = t('workspaceSettings.logo.saved');
isLogoDialogOpen.value = false;
} catch (error) {
console.error('Failed to update workspace logo:', error);
logoError.value = t('workspaceSettings.errors.logoUploadFailed');
}
}
async function submitInvite() {
if (!inviteForm.email.trim() || !inviteForm.role) {
return;
@@ -133,31 +215,93 @@
>
<article class="settings-card">
<div class="section-copy">
<span class="section-kicker">{{ t('workspaceSettings.general.summaryTitle') }}</span>
<p>{{ t('workspaceSettings.general.summaryDescription') }}</p>
<span class="section-kicker">{{ t('workspaceSettings.general.detailsTitle') }}</span>
<p>{{ t('workspaceSettings.general.detailsDescription') }}</p>
</div>
<dl
v-if="workspaceStore.activeWorkspace"
class="summary-grid"
<div
v-if="settingsError"
class="page-message error"
>
<div>
<dt>{{ t('workspaceSettings.summary.name') }}</dt>
<dd>{{ workspaceStore.activeWorkspace.name }}</dd>
{{ settingsError }}
</div>
<div
v-if="settingsStatus"
class="page-message success"
>
{{ settingsStatus }}
</div>
<form
v-if="workspaceStore.activeWorkspace"
class="form-stack"
@submit.prevent="submitWorkspaceSettings"
>
<div class="logo-picker-card">
<AppAvatar
:name="settingsForm.name || workspaceStore.activeWorkspace.name"
:src="workspaceStore.activeWorkspace.logoUrl"
size="lg"
/>
<div class="logo-picker-copy">
<strong>{{ t('workspaceSettings.logo.title') }}</strong>
<small>{{ t('workspaceSettings.logo.description') }}</small>
<small
v-if="logoError"
class="field-error"
>
{{ logoError }}
</small>
<small
v-if="logoStatus"
class="field-success"
>
{{ logoStatus }}
</small>
</div>
<button
class="secondary-button"
type="button"
:disabled="workspaceStore.isUploadingLogo"
@click="isLogoDialogOpen = true"
>
{{ workspaceStore.isUploadingLogo ? t('common.saving') : t('workspaceSettings.logo.changeAction') }}
</button>
</div>
<div>
<dt>{{ t('workspaceSettings.summary.slug') }}</dt>
<dd>{{ workspaceStore.activeWorkspace.slug }}</dd>
</div>
<div>
<dt>{{ t('workspaceSettings.summary.timeZone') }}</dt>
<dd>{{ workspaceStore.activeWorkspace.timeZone }}</dd>
</div>
<div>
<dt>{{ t('workspaceSettings.summary.created') }}</dt>
<dd>{{ formatDate(workspaceStore.activeWorkspace.createdAt) }}</dd>
</div>
</dl>
<label class="field">
<span>{{ t('workspaceSettings.fields.name') }}</span>
<input
v-model="settingsForm.name"
type="text"
:disabled="workspaceStore.isUpdating"
/>
</label>
<label class="field">
<span>{{ t('workspaceSettings.fields.timeZone') }}</span>
<TimeZoneSelect
v-model="settingsForm.timeZone"
:disabled="workspaceStore.isUpdating"
/>
</label>
<button
class="primary-button"
type="submit"
:disabled="workspaceStore.isUpdating || !isSettingsDirty"
>
{{ workspaceStore.isUpdating ? t('common.saving') : t('workspaceSettings.general.saveAction') }}
</button>
</form>
<div
v-else
class="empty-state"
>
{{ t('workspaceSettings.noWorkspaceSelected') }}
</div>
</article>
</div>
@@ -366,6 +510,16 @@
</router-link>
</article>
</div>
<ImageCropperDialog
v-model="isLogoDialogOpen"
:title="t('workspaceSettings.logo.cropperTitle')"
:confirm-label="t('workspaceSettings.logo.saveAction')"
:upload-label="t('workspaceSettings.logo.chooseAction')"
:initial-url="workspaceStore.activeWorkspace?.logoUrl"
:is-saving="workspaceStore.isUploadingLogo"
@save="saveWorkspaceLogo"
/>
</section>
</template>
@@ -426,7 +580,6 @@
}
.section-copy h1,
.summary-grid dd,
.invite-row strong,
.connector-copy strong,
.connector-status,
@@ -440,7 +593,6 @@
}
.section-copy p,
.summary-grid dt,
.invite-row span,
.invite-row small,
.empty-state,
@@ -452,22 +604,32 @@
color: #526178;
}
.summary-grid {
@apply grid gap-4 sm:grid-cols-2;
}
.summary-grid div {
@apply rounded-[1rem] border p-4;
background: #f8fafc;
.logo-picker-card {
@apply flex flex-col gap-4 rounded-[1rem] border p-4 sm:flex-row sm:items-center;
background: #fffaf2;
border-color: rgba(23, 32, 51, 0.08);
}
.summary-grid dt {
@apply text-xs font-bold uppercase tracking-[0.16em];
.logo-picker-copy {
@apply flex min-w-0 flex-1 flex-col gap-1;
}
.summary-grid dd {
@apply mt-2 text-base font-semibold;
.logo-picker-copy strong {
color: #172033;
}
.logo-picker-copy small,
.field-error,
.field-success {
@apply text-sm leading-6;
}
.field-error {
color: #b91c1c;
}
.field-success {
color: #0f766e;
}
.form-stack {
@@ -498,6 +660,18 @@
color: #fffaf2;
}
.secondary-button {
@apply inline-flex items-center justify-center rounded-full px-4 py-2 text-sm font-semibold;
background: rgba(23, 32, 51, 0.08);
color: #172033;
}
.primary-button:disabled,
.secondary-button:disabled {
cursor: not-allowed;
opacity: 0.56;
}
.invite-list,
.connector-list,
.workflow-rule-list,

View File

@@ -35,7 +35,8 @@
"website": "Website",
"common": {
"cancel": "Cancel",
"creating": "Creating..."
"creating": "Creating...",
"saving": "Saving..."
},
"workspaceSelector": {
"createAction": "Add workspace"
@@ -334,12 +335,13 @@
"errors": {
"required": "All workspace fields are required.",
"createFailed": "The workspace could not be created.",
"updateFailed": "The workspace settings could not be saved.",
"logoUploadFailed": "The workspace logo could not be saved.",
"inviteRequired": "Email and role are required to invite a member.",
"inviteFailed": "The workspace invite could not be created."
},
"fields": {
"name": "Workspace name",
"slug": "Workspace slug",
"timeZone": "Time zone",
"memberEmail": "Member email",
"memberRole": "Role"
@@ -353,9 +355,7 @@
},
"summary": {
"name": "Name",
"slug": "Slug",
"timeZone": "Time zone",
"created": "Created"
"timeZone": "Time zone"
},
"tabs": {
"general": "General",
@@ -382,8 +382,19 @@
}
},
"general": {
"summaryTitle": "Workspace summary",
"summaryDescription": "Reference details for the workspace currently in context."
"detailsTitle": "Workspace details",
"detailsDescription": "Update the workspace name and default time zone used across schedules and workspace views.",
"saveAction": "Save workspace",
"saved": "Workspace settings saved."
},
"logo": {
"title": "Workspace logo",
"description": "Use a local file or remote image, then crop it for the workspace.",
"changeAction": "Change image",
"cropperTitle": "Update workspace logo",
"saveAction": "Save logo",
"chooseAction": "Choose logo",
"saved": "Workspace logo saved."
},
"approvals": {
"flowTitle": "Approval flow",

View File

@@ -35,7 +35,8 @@
"website": "Site web",
"common": {
"cancel": "Annuler",
"creating": "Création..."
"creating": "Création...",
"saving": "Enregistrement..."
},
"workspaceSelector": {
"createAction": "Ajouter un espace"
@@ -334,12 +335,13 @@
"errors": {
"required": "Tous les champs de l'espace sont requis.",
"createFailed": "L'espace n'a pas pu être créé.",
"updateFailed": "Les paramètres de l'espace n'ont pas pu être enregistrés.",
"logoUploadFailed": "Le logo de l'espace n'a pas pu être enregistré.",
"inviteRequired": "L'email et le rôle sont requis pour inviter un membre.",
"inviteFailed": "L'invitation de l'espace n'a pas pu être créée."
},
"fields": {
"name": "Nom de l'espace",
"slug": "Slug de l'espace",
"timeZone": "Fuseau horaire",
"memberEmail": "Email du membre",
"memberRole": "Rôle"
@@ -353,9 +355,7 @@
},
"summary": {
"name": "Nom",
"slug": "Slug",
"timeZone": "Fuseau horaire",
"created": "Créé"
"timeZone": "Fuseau horaire"
},
"tabs": {
"general": "Général",
@@ -382,8 +382,19 @@
}
},
"general": {
"summaryTitle": "Résumé de l'espace",
"summaryDescription": "Détails de référence pour l'espace actuellement en contexte."
"detailsTitle": "Détails de l'espace",
"detailsDescription": "Mettez à jour le nom de l'espace et le fuseau horaire par défaut utilisés dans les calendriers et les vues de l'espace.",
"saveAction": "Enregistrer l'espace",
"saved": "Paramètres de l'espace enregistrés."
},
"logo": {
"title": "Logo de l'espace",
"description": "Utilisez un fichier local ou une image distante, puis recadrez-la pour l'espace.",
"changeAction": "Changer l'image",
"cropperTitle": "Mettre à jour le logo de l'espace",
"saveAction": "Enregistrer le logo",
"chooseAction": "Choisir un logo",
"saved": "Logo de l'espace enregistré."
},
"approvals": {
"flowTitle": "Flux d'approbation",