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

View File

@@ -50,7 +50,7 @@ public static class DependencyInjection
{
using IServiceScope scope = app.ApplicationServices.CreateScope();
await using AppDbContext context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await context.Database.EnsureCreatedAsync(cancellationToken);
await context.Database.MigrateAsync(cancellationToken);
return app;
}

View File

@@ -4,5 +4,6 @@ internal static class ContainerNames
{
public const string Users = "users";
public const string Clients = "clients";
public const string Workspaces = "workspaces";
public const string Creators = "creators";
}

View File

@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Socialize.Api.Data;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
[DbContext(typeof(AppDbContext))]
[Migration("20260430054500_AddWorkspaceLogo")]
public partial class AddWorkspaceLogo : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "LogoUrl",
table: "Workspaces",
type: "character varying(2048)",
maxLength: 2048,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LogoUrl",
table: "Workspaces");
}
}
}

View File

@@ -819,6 +819,10 @@ namespace Socialize.Api.Migrations
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("LogoUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");

View File

@@ -5,6 +5,7 @@ public class Workspace
public Guid Id { get; init; }
public required string Name { get; set; }
public required string Slug { get; set; }
public string? LogoUrl { get; set; }
public Guid OwnerUserId { get; set; }
public required string TimeZone { get; set; }
public DateTimeOffset CreatedAt { get; init; }

View File

@@ -12,6 +12,7 @@ public static class WorkspaceModelConfiguration
workspace.HasKey(x => x.Id);
workspace.Property(x => x.Name).HasMaxLength(256).IsRequired();
workspace.Property(x => x.Slug).HasMaxLength(128).IsRequired();
workspace.Property(x => x.LogoUrl).HasMaxLength(2048);
workspace.Property(x => x.TimeZone).HasMaxLength(128).IsRequired();
workspace.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()

View File

@@ -0,0 +1,68 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Workspaces.Handlers;
public record ChangeWorkspaceLogoRequest(
IFormFile File);
public record ChangeWorkspaceLogoResponse(
string BlobUrl);
public sealed class ChangeWorkspaceLogoRequestValidator : Validator<ChangeWorkspaceLogoRequest>
{
public ChangeWorkspaceLogoRequestValidator()
{
RuleFor(x => x.File)
.NotNull()
.NotEmpty();
}
}
public class ChangeWorkspaceLogoHandler(
AppDbContext dbContext,
IBlobStorage blobStorage,
AccessScopeService accessScopeService)
: Endpoint<ChangeWorkspaceLogoRequest, ChangeWorkspaceLogoResponse>
{
public override void Configure()
{
Post("/api/workspaces/{id}/logo");
Options(o => o.WithTags("Workspaces"));
AllowFileUploads();
}
public override async Task HandleAsync(ChangeWorkspaceLogoRequest request, CancellationToken ct)
{
Guid id = Route<Guid>("id");
Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (workspace is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!accessScopeService.CanManageWorkspace(User, workspace.Id))
{
await SendForbiddenAsync(ct);
return;
}
string blobUrl = await blobStorage.UploadFileAsync(
ContainerNames.Workspaces,
$"{workspace.Id}/{SubDirectoryNames.Profile}/{CommonFileNames.LogoPicture}",
request.File.OpenReadStream(),
request.File.ContentType,
ct);
workspace.LogoUrl = blobUrl;
await dbContext.SaveChangesAsync(ct);
await SendOkAsync(new ChangeWorkspaceLogoResponse(blobUrl), ct);
}
}

View File

@@ -75,6 +75,7 @@ public class CreateWorkspaceHandler(
workspace.Id,
workspace.Name,
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.CreatedAt);

View File

@@ -10,6 +10,7 @@ public record WorkspaceDto(
Guid Id,
string Name,
string Slug,
string? LogoUrl,
string TimeZone,
DateTimeOffset CreatedAt);
@@ -40,6 +41,7 @@ public class GetWorkspacesHandler(
workspace.Id,
workspace.Name,
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.CreatedAt))
.ToListAsync(ct);

View File

@@ -0,0 +1,66 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Workspaces.Handlers;
public record UpdateWorkspaceRequest(
string Name,
string TimeZone);
public class UpdateWorkspaceRequestValidator
: Validator<UpdateWorkspaceRequest>
{
public UpdateWorkspaceRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.TimeZone).NotEmpty().MaximumLength(128);
}
}
public class UpdateWorkspaceHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService)
: Endpoint<UpdateWorkspaceRequest, WorkspaceDto>
{
public override void Configure()
{
Put("/api/workspaces/{id}");
Options(o => o.WithTags("Workspaces"));
}
public override async Task HandleAsync(UpdateWorkspaceRequest request, CancellationToken ct)
{
Guid id = Route<Guid>("id");
Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
if (workspace is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!accessScopeService.CanManageWorkspace(User, workspace.Id))
{
await SendForbiddenAsync(ct);
return;
}
workspace.Name = request.Name.Trim();
workspace.TimeZone = request.TimeZone.Trim();
await dbContext.SaveChangesAsync(ct);
WorkspaceDto dto = new(
workspace.Id,
workspace.Name,
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.CreatedAt);
await SendOkAsync(dto, ct);
}
}

View File

@@ -0,0 +1,24 @@
# Task: Edit workspace settings
## Goal
Allow managers to update the active workspace name and time zone from the workspace settings page.
## Feature Spec
- `docs/FEATURES/workspace-review-workflow.md`
## Scope
- Add a backend workspace update endpoint for `name` and `timeZone`.
- Add a backend workspace logo upload endpoint.
- Add a frontend workspace store update action.
- Replace the workspace settings general summary with editable details and logo controls.
- Do not display workspace slug or workspace creation date on the workspace settings page.
## Validation
```bash
dotnet build backend/Socialize.slnx
cd frontend && npm run build
```

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",

3799
shared/openapi/openapi.json Normal file

File diff suppressed because it is too large Load Diff