Compare commits

..

10 Commits

Author SHA1 Message Date
664eb07201 Polish workspace organization selector
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
2026-05-04 17:44:39 -04:00
58c1301054 refactor: remove organization slug 2026-05-04 17:41:50 -04:00
552f4f1f21 fix: collapse sidebar by default on small screens 2026-05-04 16:40:43 -04:00
8f4b95f311 feat: add organization settings UI 2026-05-04 16:33:34 -04:00
4fba72e99c feat: prerender public site pages 2026-05-04 16:29:50 -04:00
55d8acef4c Refine content approval workflow rail 2026-05-04 16:20:32 -04:00
7d3f495472 feat: add organization domain foundation 2026-05-04 16:15:53 -04:00
802668fb0b feat: add public site pages and social login 2026-05-04 16:13:57 -04:00
cd6f402d9e docs: define organization account model 2026-05-04 15:45:12 -04:00
9bdef978bd refactor: align main layout shell 2026-05-04 14:46:13 -04:00
103 changed files with 6167 additions and 1094 deletions

View File

@@ -70,6 +70,7 @@ Update OpenAPI:
## Current Domain Modules
- `Identity`: authentication, refresh tokens, email verification, password reset, social login.
- `Organizations`: SaaS account ownership, billing/subscription boundary, organization membership, connectors, data mappings, and owned workspaces.
- `Workspaces`: workspace membership, workspace settings, access scoping.
- `Clients`: client records and primary contacts tied to workspaces.
- `Projects`: project pipeline and client/project relationships.

View File

@@ -1,6 +1,6 @@
# Socialize
Socialize is a workspace-based workflow application for social media content review, revision, approval, and publication readiness.
Socialize is an organization-owned, workspace-based workflow application for social media content review, revision, approval, and publication readiness.
It is not a public social network. The product is for internal teams, providers, and client approvers coordinating content work before publication.

View File

@@ -9,6 +9,7 @@ using Socialize.Api.Modules.Feedback.Data;
using Socialize.Api.Modules.Identity.Data;
using Socialize.Api.Modules.Notifications.Data;
using Socialize.Api.Modules.Campaigns.Data;
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Data;
@@ -17,6 +18,8 @@ public class AppDbContext(
DbContextOptions<AppDbContext> options)
: IdentityDbContext<User, Role, Guid>(options)
{
public DbSet<Organization> Organizations => Set<Organization>();
public DbSet<OrganizationMembership> OrganizationMemberships => Set<OrganizationMembership>();
public DbSet<Workspace> Workspaces => Set<Workspace>();
public DbSet<WorkspaceInvite> WorkspaceInvites => Set<WorkspaceInvite>();
public DbSet<Client> Clients => Set<Client>();
@@ -41,6 +44,7 @@ public class AppDbContext(
{
base.OnModelCreating(builder);
builder.ConfigureOrganizationsModule();
builder.ConfigureWorkspacesModule();
builder.ConfigureClientsModule();
builder.ConfigureCampaignsModule();

View File

@@ -11,6 +11,8 @@ using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Clients.Data;
using Socialize.Api.Modules.Notifications.Data;
using Socialize.Api.Modules.Campaigns.Data;
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Organizations.Services;
using Socialize.Api.Modules.Workspaces.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
@@ -19,6 +21,7 @@ namespace Socialize.Api.Infrastructure.Development;
public static class DevelopmentSeedExtensions
{
private static readonly Guid OrganizationId = Guid.Parse("99999999-9999-9999-9999-999999999999");
private static readonly Guid WorkspaceId = Guid.Parse("11111111-1111-1111-1111-111111111111");
private static readonly Guid ScopedClientId = Guid.Parse("22222222-2222-2222-2222-222222222222");
private static readonly Guid HiddenClientId = Guid.Parse("22222222-2222-2222-2222-333333333333");
@@ -117,6 +120,12 @@ public static class DevelopmentSeedExtensions
[
]);
await EnsureOrganizationDataAsync(
manager.Id,
dev.Id,
dbContext,
cancellationToken);
await EnsureWorkspaceDataAsync(
manager.Id,
clientUser.Id,
@@ -224,6 +233,75 @@ public static class DevelopmentSeedExtensions
return user;
}
private static async Task EnsureOrganizationDataAsync(
Guid managerUserId,
Guid developerUserId,
AppDbContext dbContext,
CancellationToken cancellationToken)
{
Organization? organization = await dbContext.Organizations
.SingleOrDefaultAsync(candidate => candidate.Id == OrganizationId, cancellationToken);
if (organization is null)
{
organization = new Organization
{
Id = OrganizationId,
Name = string.Empty,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Organizations.Add(organization);
}
organization.Name = "Northstar Collective";
organization.OwnerUserId = managerUserId;
await UpsertOrganizationMembershipAsync(
dbContext,
Guid.Parse("99999999-9999-9999-9999-000000000001"),
OrganizationId,
managerUserId,
OrganizationRoles.Owner,
cancellationToken);
await UpsertOrganizationMembershipAsync(
dbContext,
Guid.Parse("99999999-9999-9999-9999-000000000002"),
OrganizationId,
developerUserId,
OrganizationRoles.Admin,
cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
}
private static async Task UpsertOrganizationMembershipAsync(
AppDbContext dbContext,
Guid membershipId,
Guid organizationId,
Guid userId,
string role,
CancellationToken cancellationToken)
{
OrganizationMembership? membership = await dbContext.OrganizationMemberships
.SingleOrDefaultAsync(
candidate => candidate.OrganizationId == organizationId && candidate.UserId == userId,
cancellationToken);
if (membership is null)
{
membership = new OrganizationMembership
{
Id = membershipId,
OrganizationId = organizationId,
UserId = userId,
Role = role,
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.OrganizationMemberships.Add(membership);
}
membership.Role = role;
}
private static async Task EnsureWorkspaceDataAsync(
Guid managerUserId,
Guid clientUserId,
@@ -248,6 +326,7 @@ public static class DevelopmentSeedExtensions
workspace.Name = "Northstar Studio";
workspace.Slug = "northstar-studio";
workspace.OrganizationId = OrganizationId;
workspace.OwnerUserId = managerUserId;
workspace.TimeZone = "America/Montreal";
await dbContext.SaveChangesAsync(cancellationToken);

View File

@@ -1,9 +1,11 @@
using System.Security.Claims;
using Socialize.Api.Modules.Identity.Contracts;
using Socialize.Api.Modules.Organizations.Services;
namespace Socialize.Api.Infrastructure.Security;
public sealed class AccessScopeService
public sealed class AccessScopeService(
OrganizationAccessService organizationAccessService)
{
public bool IsManager(ClaimsPrincipal user)
{
@@ -53,4 +55,123 @@ public sealed class AccessScopeService
|| IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId)
|| IsClient(user) && CanAccessClient(user, workspaceId, clientId);
}
public Task<IReadOnlyCollection<Guid>> GetAccessibleWorkspaceIdsAsync(
ClaimsPrincipal user,
CancellationToken ct)
{
return organizationAccessService.GetAccessibleWorkspaceIdsAsync(user, ct);
}
public async Task<bool> CanAccessWorkspaceAsync(
ClaimsPrincipal user,
Guid workspaceId,
CancellationToken ct)
{
return CanAccessWorkspace(user, workspaceId)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.AccessOwnedWorkspaces,
ct);
}
public async Task<bool> CanManageWorkspaceAsync(
ClaimsPrincipal user,
Guid workspaceId,
CancellationToken ct)
{
return IsManager(user)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.ManageWorkspaces,
ct);
}
public async Task<bool> CanCreateWorkspaceAsync(
ClaimsPrincipal user,
Guid organizationId,
CancellationToken ct)
{
return IsManager(user)
|| await organizationAccessService.HasOrganizationPermissionAsync(
user,
organizationId,
OrganizationPermissions.CreateWorkspaces,
ct);
}
public async Task<bool> CanAccessClientAsync(
ClaimsPrincipal user,
Guid workspaceId,
Guid clientId,
CancellationToken ct)
{
if (IsManager(user) ||
await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.AccessOwnedWorkspaces,
ct))
{
return true;
}
return user.GetWorkspaceScopeIds().Contains(workspaceId) && user.GetClientScopeIds().Contains(clientId);
}
public async Task<bool> CanAccessCampaignAsync(
ClaimsPrincipal user,
Guid workspaceId,
Guid clientId,
Guid campaignId,
CancellationToken ct)
{
if (IsManager(user) ||
await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.AccessOwnedWorkspaces,
ct))
{
return true;
}
return await CanAccessClientAsync(user, workspaceId, clientId, ct) &&
user.GetCampaignScopeIds().Contains(campaignId);
}
public async Task<bool> CanContributeToCampaignAsync(
ClaimsPrincipal user,
Guid workspaceId,
Guid clientId,
Guid campaignId,
CancellationToken ct)
{
return IsManager(user)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.ManageWorkspaces,
ct)
|| IsProvider(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId, ct);
}
public async Task<bool> CanReviewContentAsync(
ClaimsPrincipal user,
Guid workspaceId,
Guid clientId,
Guid campaignId,
CancellationToken ct)
{
return IsManager(user)
|| await organizationAccessService.HasInheritedWorkspacePermissionAsync(
user,
workspaceId,
OrganizationPermissions.AccessOwnedWorkspaces,
ct)
|| IsProvider(user) && await CanAccessCampaignAsync(user, workspaceId, clientId, campaignId, ct)
|| IsClient(user) && await CanAccessClientAsync(user, workspaceId, clientId, ct);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,126 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Socialize.Api.Migrations
{
/// <inheritdoc />
public partial class AddOrganizations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "OrganizationId",
table: "Workspaces",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
migrationBuilder.CreateTable(
name: "Organizations",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
OwnerUserId = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_Organizations", x => x.Id);
});
migrationBuilder.CreateTable(
name: "OrganizationMemberships",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Role = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
},
constraints: table =>
{
table.PrimaryKey("PK_OrganizationMemberships", x => x.Id);
table.ForeignKey(
name: "FK_OrganizationMemberships_Organizations_OrganizationId",
column: x => x.OrganizationId,
principalTable: "Organizations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.Sql(
"""
INSERT INTO "Organizations" ("Id", "Name", "OwnerUserId", "CreatedAt")
VALUES ('99999999-9999-9999-9999-999999999999', 'Northstar Collective', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', CURRENT_TIMESTAMP);
UPDATE "Workspaces"
SET "OrganizationId" = '99999999-9999-9999-9999-999999999999'
WHERE "OrganizationId" = '00000000-0000-0000-0000-000000000000';
INSERT INTO "OrganizationMemberships" ("Id", "OrganizationId", "UserId", "Role", "CreatedAt")
VALUES ('99999999-9999-9999-9999-000000000001', '99999999-9999-9999-9999-999999999999', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Owner', CURRENT_TIMESTAMP);
""");
migrationBuilder.CreateIndex(
name: "IX_Workspaces_OrganizationId",
table: "Workspaces",
column: "OrganizationId");
migrationBuilder.CreateIndex(
name: "IX_OrganizationMemberships_OrganizationId",
table: "OrganizationMemberships",
column: "OrganizationId");
migrationBuilder.CreateIndex(
name: "IX_OrganizationMemberships_OrganizationId_UserId",
table: "OrganizationMemberships",
columns: new[] { "OrganizationId", "UserId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OrganizationMemberships_UserId",
table: "OrganizationMemberships",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_Organizations_OwnerUserId",
table: "Organizations",
column: "OwnerUserId");
migrationBuilder.AddForeignKey(
name: "FK_Workspaces_Organizations_OrganizationId",
table: "Workspaces",
column: "OrganizationId",
principalTable: "Organizations",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Workspaces_Organizations_OrganizationId",
table: "Workspaces");
migrationBuilder.DropTable(
name: "OrganizationMemberships");
migrationBuilder.DropTable(
name: "Organizations");
migrationBuilder.DropIndex(
name: "IX_Workspaces_OrganizationId",
table: "Workspaces");
migrationBuilder.DropColumn(
name: "OrganizationId",
table: "Workspaces");
}
}
}

View File

@@ -1203,6 +1203,66 @@ namespace Socialize.Api.Migrations
b.ToTable("NotificationEvents", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.Organization", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OwnerUserId");
b.ToTable("Organizations", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembership", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("CURRENT_TIMESTAMP");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.HasIndex("UserId");
b.HasIndex("OrganizationId", "UserId")
.IsUnique();
b.ToTable("OrganizationMemberships", (string)null);
});
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
{
b.Property<Guid>("Id")
@@ -1235,6 +1295,9 @@ namespace Socialize.Api.Migrations
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<Guid>("OwnerUserId")
.HasColumnType("uuid");
@@ -1260,6 +1323,8 @@ namespace Socialize.Api.Migrations
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.HasIndex("OwnerUserId");
b.HasIndex("Slug")
@@ -1404,6 +1469,24 @@ namespace Socialize.Api.Migrations
b.Navigation("FeedbackReport");
});
modelBuilder.Entity("Socialize.Api.Modules.Organizations.Data.OrganizationMembership", b =>
{
b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null)
.WithMany()
.HasForeignKey("OrganizationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
{
b.HasOne("Socialize.Api.Modules.Organizations.Data.Organization", null)
.WithMany()
.HasForeignKey("OrganizationId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b =>
{
b.Navigation("ActivityEntries");

View File

@@ -1,139 +0,0 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Approvals.Data;
using Socialize.Api.Modules.Approvals.Services;
using Socialize.Api.Modules.Notifications.Contracts;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Approvals.Handlers;
public record CreateApprovalRequestRequest(
Guid WorkspaceId,
Guid ContentItemId,
string Stage,
string ReviewerName,
string ReviewerEmail,
DateTimeOffset? DueAt);
public class CreateApprovalRequestRequestValidator
: Validator<CreateApprovalRequestRequest>
{
public CreateApprovalRequestRequestValidator()
{
RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.ContentItemId).NotEmpty();
RuleFor(x => x.Stage).NotEmpty().MaximumLength(64);
RuleFor(x => x.ReviewerName).NotEmpty().MaximumLength(256);
RuleFor(x => x.ReviewerEmail).NotEmpty().MaximumLength(256).EmailAddress();
}
}
public class CreateApprovalRequestHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
INotificationEventWriter notificationEventWriter)
: Endpoint<CreateApprovalRequestRequest, ApprovalRequestDto>
{
public override void Configure()
{
Post("/api/approvals");
Options(o => o.WithTags("Approvals"));
}
public override async Task HandleAsync(CreateApprovalRequestRequest request, CancellationToken ct)
{
var contentItem = await dbContext
.ContentItems
.SingleOrDefaultAsync(
candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId,
ct);
if (contentItem is null)
{
AddError(request => request.ContentItemId, "The selected content item does not exist in the active workspace.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
if (!accessScopeService.CanManageWorkspace(User, contentItem.WorkspaceId))
{
await SendForbiddenAsync(ct);
return;
}
Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == contentItem.WorkspaceId, ct);
if (workspace is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!ApprovalWorkflowRules.CanCreateSingleStepApprovalRequest(workspace.ApprovalMode))
{
AddError(request => request.WorkspaceId, workspace.ApprovalMode == ApprovalModes.None
? "Approval workflow is disabled for this workspace."
: "Move content to In approval to start the configured multi-level approval workflow.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
var approval = new ApprovalRequest()
{
Id = Guid.NewGuid(),
WorkspaceId = request.WorkspaceId,
ContentItemId = request.ContentItemId,
Stage = request.Stage.Trim(),
ReviewerName = request.ReviewerName.Trim(),
ReviewerEmail = request.ReviewerEmail.Trim(),
RequestedByUserId = User.GetUserId(),
DueAt = request.DueAt,
State = "Pending",
AccessToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(),
SentAt = DateTimeOffset.UtcNow,
};
dbContext.ApprovalRequests.Add(approval);
contentItem.Status = "In approval";
await dbContext.SaveChangesAsync(ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
approval.WorkspaceId,
approval.ContentItemId,
"approval.requested",
"ApprovalRequest",
approval.Id,
$"Approval requested from {approval.ReviewerName} for {contentItem.Title}.",
null,
approval.ReviewerEmail,
$$"""{"stage":"{{approval.Stage}}","accessToken":"{{approval.AccessToken}}"}"""),
ct);
ApprovalRequestDto dto = new(
approval.Id,
approval.WorkspaceId,
approval.ContentItemId,
approval.WorkflowInstanceId,
approval.WorkflowStepSortOrder,
approval.WorkflowStepTargetType,
approval.WorkflowStepTargetValue,
approval.WorkflowStepRequiredApproverCount,
approval.Stage,
approval.ReviewerName,
approval.ReviewerEmail,
approval.RequestedByUserId,
approval.DueAt,
approval.State,
approval.AccessToken,
approval.SentAt,
approval.CompletedAt,
[]);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}
}

View File

@@ -61,7 +61,7 @@ public class GetApprovalsHandler(
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId))
if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -12,7 +12,6 @@ namespace Socialize.Api.Modules.Approvals.Handlers;
public record SubmitApprovalDecisionRequest(
string Decision,
string? Comment,
string? ReviewerName,
string? ReviewerEmail);
@@ -25,7 +24,6 @@ public class SubmitApprovalDecisionRequestValidator
.NotEmpty()
.Equal("Approved")
.WithMessage("Only approved decisions are supported.");
RuleFor(x => x.Comment).MaximumLength(2048);
RuleFor(x => x.ReviewerName).MaximumLength(256);
RuleFor(x => x.ReviewerEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.ReviewerEmail));
}
@@ -64,7 +62,7 @@ public class SubmitApprovalDecisionHandler(
}
if (User?.Identity?.IsAuthenticated == true &&
!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId))
!await accessScopeService.CanReviewContentAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;
@@ -90,7 +88,7 @@ public class SubmitApprovalDecisionHandler(
Id = Guid.NewGuid(),
ApprovalRequestId = approval.Id,
Decision = normalizedDecision,
Comment = string.IsNullOrWhiteSpace(request.Comment) ? null : request.Comment.Trim(),
Comment = null,
DecidedByUserId = User?.Identity?.IsAuthenticated == true ? User.GetUserId() : null,
DecidedByName = decidedByName,
DecidedByEmail = decidedByEmail,

View File

@@ -12,11 +12,6 @@ public static class ApprovalModes
public static class ApprovalWorkflowRules
{
public static bool CanCreateSingleStepApprovalRequest(string approvalMode)
{
return approvalMode is ApprovalModes.Optional or ApprovalModes.Required;
}
public static bool BlocksManualApprovedOrScheduledStatus(string approvalMode)
{
return approvalMode is ApprovalModes.Required or ApprovalModes.MultiLevel;

View File

@@ -51,7 +51,7 @@ public class CreateAssetRevisionHandler(
.SingleOrDefaultAsync(candidate => candidate.Id == asset.ContentItemId, ct);
if (contentItem is not null &&
!accessScopeService.CanContributeToCampaign(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId))
!await accessScopeService.CanContributeToCampaignAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -58,7 +58,7 @@ public class CreateGoogleDriveAssetHandler(
return;
}
if (!accessScopeService.CanContributeToCampaign(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId))
if (!await accessScopeService.CanContributeToCampaignAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -52,7 +52,7 @@ public class GetAssetsHandler(
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId))
if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -45,7 +45,7 @@ public class CreateCampaignHandler(
public override async Task HandleAsync(CreateCampaignRequest request, CancellationToken ct)
{
if (!accessScopeService.CanManageWorkspace(User, request.WorkspaceId))
if (!await accessScopeService.CanManageWorkspaceAsync(User, request.WorkspaceId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -34,16 +34,9 @@ public class GetCampaignsHandler(
{
IQueryable<Campaign> query = dbContext.Campaigns.AsQueryable();
if (accessScopeService.IsManager(User))
if (!accessScopeService.IsManager(User))
{
if (request.WorkspaceId.HasValue)
{
query = query.Where(campaign => campaign.WorkspaceId == request.WorkspaceId.Value);
}
}
else
{
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
IReadOnlyCollection<Guid> campaignScopeIds = User.GetCampaignScopeIds();

View File

@@ -47,7 +47,7 @@ public class ChangeClientPortraitHandler(
return;
}
if (!accessScopeService.CanManageWorkspace(User, client.WorkspaceId))
if (!await accessScopeService.CanManageWorkspaceAsync(User, client.WorkspaceId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -41,7 +41,7 @@ public class CreateClientHandler(
public override async Task HandleAsync(CreateClientRequest request, CancellationToken ct)
{
if (!accessScopeService.CanManageWorkspace(User, request.WorkspaceId))
if (!await accessScopeService.CanManageWorkspaceAsync(User, request.WorkspaceId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -33,16 +33,9 @@ public class GetClientsHandler(
{
IQueryable<Client> query = dbContext.Clients.AsQueryable();
if (accessScopeService.IsManager(User))
if (!accessScopeService.IsManager(User))
{
if (request.WorkspaceId.HasValue)
{
query = query.Where(client => client.WorkspaceId == request.WorkspaceId.Value);
}
}
else
{
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
query = query.Where(client => workspaceScopeIds.Contains(client.WorkspaceId));
@@ -52,10 +45,11 @@ public class GetClientsHandler(
query = query.Where(client => clientScopeIds.Contains(client.Id));
}
if (request.WorkspaceId.HasValue)
{
query = query.Where(client => client.WorkspaceId == request.WorkspaceId.Value);
}
}
if (request.WorkspaceId.HasValue)
{
query = query.Where(client => client.WorkspaceId == request.WorkspaceId.Value);
}
List<ClientDto> clients = await query

View File

@@ -50,7 +50,7 @@ public class UpdateClientHandler(
return;
}
if (!accessScopeService.CanManageWorkspace(User, client.WorkspaceId))
if (!await accessScopeService.CanManageWorkspaceAsync(User, client.WorkspaceId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -51,7 +51,7 @@ public class CreateCommentHandler(
return;
}
if (!accessScopeService.CanReviewContent(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId))
if (!await accessScopeService.CanReviewContentAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -44,7 +44,7 @@ public class GetCommentsHandler(
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId))
if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -39,8 +39,8 @@ public class ResolveCommentHandler(
return;
}
bool canResolve = accessScopeService.CanManageWorkspace(User, comment.WorkspaceId)
|| accessScopeService.CanContributeToCampaign(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId);
bool canResolve = await accessScopeService.CanManageWorkspaceAsync(User, comment.WorkspaceId, ct)
|| await accessScopeService.CanContributeToCampaignAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct);
if (!canResolve)
{

View File

@@ -47,7 +47,7 @@ public class CreateContentItemHandler(
public override async Task HandleAsync(CreateContentItemRequest request, CancellationToken ct)
{
if (!accessScopeService.CanContributeToCampaign(User, request.WorkspaceId, request.ClientId, request.CampaignId))
if (!await accessScopeService.CanContributeToCampaignAsync(User, request.WorkspaceId, request.ClientId, request.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -50,7 +50,7 @@ public class CreateContentItemRevisionHandler(
return;
}
if (!accessScopeService.CanContributeToCampaign(User, item.WorkspaceId, item.ClientId, item.CampaignId))
if (!await accessScopeService.CanContributeToCampaignAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -60,7 +60,7 @@ public class GetContentItemHandler(
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId))
if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -41,7 +41,7 @@ public class GetContentItemRevisionsHandler(
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId))
if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -39,7 +39,7 @@ public class GetContentItemsHandler(
if (!accessScopeService.IsManager(User))
{
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
IReadOnlyCollection<Guid> campaignScopeIds = User.GetCampaignScopeIds();

View File

@@ -54,7 +54,7 @@ public class UpdateContentItemStatusHandler(
return;
}
if (!accessScopeService.CanManageWorkspace(User, item.WorkspaceId))
if (!await accessScopeService.CanManageWorkspaceAsync(User, item.WorkspaceId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -46,7 +46,7 @@ public class GetNotificationsHandler(
return;
}
if (!accessScopeService.CanReviewContent(User, item.WorkspaceId, item.ClientId, item.CampaignId))
if (!await accessScopeService.CanReviewContentAsync(User, item.WorkspaceId, item.ClientId, item.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;
@@ -58,7 +58,7 @@ public class GetNotificationsHandler(
if (!accessScopeService.IsManager(User))
{
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
query = query.Where(notificationEvent =>
workspaceScopeIds.Contains(notificationEvent.WorkspaceId) ||
notificationEvent.RecipientUserId == currentUserId);

View File

@@ -30,7 +30,7 @@ public class MarkNotificationAsReadHandler(
Guid currentUserId = User.GetUserId();
bool canReadRecipientNotification = notificationEvent.RecipientUserId == currentUserId;
if (!canReadRecipientNotification && !accessScopeService.CanAccessWorkspace(User, notificationEvent.WorkspaceId))
if (!canReadRecipientNotification && !await accessScopeService.CanAccessWorkspaceAsync(User, notificationEvent.WorkspaceId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -0,0 +1,9 @@
namespace Socialize.Api.Modules.Organizations.Data;
public class Organization
{
public Guid Id { get; init; }
public required string Name { get; set; }
public Guid OwnerUserId { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,10 @@
namespace Socialize.Api.Modules.Organizations.Data;
public class OrganizationMembership
{
public Guid Id { get; init; }
public Guid OrganizationId { get; set; }
public Guid UserId { get; set; }
public required string Role { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore;
namespace Socialize.Api.Modules.Organizations.Data;
public static class OrganizationModelConfiguration
{
public static ModelBuilder ConfigureOrganizationsModule(this ModelBuilder modelBuilder)
{
modelBuilder.Entity<Organization>(organization =>
{
organization.ToTable("Organizations");
organization.HasKey(x => x.Id);
organization.Property(x => x.Name).HasMaxLength(256).IsRequired();
organization.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
organization.HasIndex(x => x.OwnerUserId);
});
modelBuilder.Entity<OrganizationMembership>(membership =>
{
membership.ToTable("OrganizationMemberships");
membership.HasKey(x => x.Id);
membership.Property(x => x.Role).HasMaxLength(64).IsRequired();
membership.Property(x => x.CreatedAt)
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
membership.HasIndex(x => x.OrganizationId);
membership.HasIndex(x => x.UserId);
membership.HasIndex(x => new { x.OrganizationId, x.UserId }).IsUnique();
membership.HasOne<Organization>()
.WithMany()
.HasForeignKey(x => x.OrganizationId)
.OnDelete(DeleteBehavior.Cascade);
});
return modelBuilder;
}
}

View File

@@ -0,0 +1,14 @@
using Socialize.Api.Modules.Organizations.Services;
namespace Socialize.Api.Modules.Organizations;
public static class DependencyInjection
{
public static WebApplicationBuilder AddOrganizationsModule(
this WebApplicationBuilder builder)
{
builder.Services.AddScoped<OrganizationAccessService>();
return builder;
}
}

View File

@@ -0,0 +1,114 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.Identity.Data;
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Organizations.Services;
using Socialize.Api.Modules.Workspaces.Handlers;
namespace Socialize.Api.Modules.Organizations.Handlers;
public class GetOrganizationHandler(
AppDbContext dbContext,
OrganizationAccessService organizationAccessService)
: EndpointWithoutRequest<OrganizationDto>
{
public override void Configure()
{
Get("/api/organizations/{organizationId:guid}");
Options(o => o.WithTags("Organizations"));
}
public override async Task HandleAsync(CancellationToken ct)
{
Guid organizationId = Route<Guid>("organizationId");
Organization? organization = await dbContext.Organizations
.SingleOrDefaultAsync(candidate => candidate.Id == organizationId, ct);
if (organization is null)
{
await SendNotFoundAsync(ct);
return;
}
if (!await organizationAccessService.CanAccessOrganizationAsync(User, organizationId, ct))
{
await SendForbiddenAsync(ct);
return;
}
IReadOnlyCollection<string> currentUserPermissions = await organizationAccessService.GetUserOrganizationPermissionsAsync(
User,
organizationId,
ct);
IReadOnlyCollection<OrganizationMemberDto> members = await GetMembersAsync(organizationId, ct);
IReadOnlyCollection<WorkspaceDto> workspaces = await GetWorkspacesAsync(organizationId, ct);
await SendOkAsync(
OrganizationDto.FromOrganization(
organization,
currentUserPermissions,
members,
workspaces),
ct);
}
private async Task<IReadOnlyCollection<OrganizationMemberDto>> GetMembersAsync(
Guid organizationId,
CancellationToken ct)
{
var rows = await dbContext.OrganizationMemberships
.Where(membership => membership.OrganizationId == organizationId)
.Join(
dbContext.Users,
membership => membership.UserId,
user => user.Id,
(membership, user) => new { Membership = membership, User = user })
.OrderBy(row => row.User.Lastname)
.ThenBy(row => row.User.Firstname)
.ThenBy(row => row.User.Email)
.ToListAsync(ct);
return rows
.Select(row => new OrganizationMemberDto(
row.User.Id,
BuildDisplayName(row.User),
row.User.Email ?? string.Empty,
row.User.PortraitUrl,
row.Membership.Role,
OrganizationPermissionRules.GetPermissionsForRole(row.Membership.Role),
row.Membership.CreatedAt))
.ToArray();
}
private async Task<IReadOnlyCollection<WorkspaceDto>> GetWorkspacesAsync(
Guid organizationId,
CancellationToken ct)
{
var workspaces = await dbContext.Workspaces
.Where(workspace => workspace.OrganizationId == organizationId)
.OrderBy(workspace => workspace.Name)
.ToListAsync(ct);
return workspaces
.Select(workspace => WorkspaceDto.FromWorkspace(workspace, []))
.ToArray();
}
private static string BuildDisplayName(User user)
{
if (!string.IsNullOrWhiteSpace(user.Alias))
{
return user.Alias;
}
string fullName = $"{user.Firstname} {user.Lastname}".Trim();
if (!string.IsNullOrWhiteSpace(fullName))
{
return fullName;
}
return user.Email ?? user.UserName ?? user.Id.ToString();
}
}

View File

@@ -0,0 +1,41 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Organizations.Services;
namespace Socialize.Api.Modules.Organizations.Handlers;
public class GetOrganizationsHandler(
AppDbContext dbContext,
OrganizationAccessService organizationAccessService)
: EndpointWithoutRequest<IReadOnlyCollection<OrganizationDto>>
{
public override void Configure()
{
Get("/api/organizations");
Options(o => o.WithTags("Organizations"));
}
public override async Task HandleAsync(CancellationToken ct)
{
IReadOnlyCollection<Guid> organizationIds = await organizationAccessService.GetAccessibleOrganizationIdsAsync(User, ct);
List<Organization> organizations = await dbContext.Organizations
.Where(organization => organizationIds.Contains(organization.Id))
.OrderBy(organization => organization.Name)
.ToListAsync(ct);
List<OrganizationDto> response = [];
foreach (Organization organization in organizations)
{
IReadOnlyCollection<string> permissions = await organizationAccessService.GetUserOrganizationPermissionsAsync(
User,
organization.Id,
ct);
response.Add(OrganizationDto.FromOrganization(organization, permissions));
}
await SendOkAsync(response, ct);
}
}

View File

@@ -0,0 +1,39 @@
using Socialize.Api.Modules.Organizations.Data;
using Socialize.Api.Modules.Workspaces.Handlers;
namespace Socialize.Api.Modules.Organizations.Handlers;
public record OrganizationMemberDto(
Guid UserId,
string DisplayName,
string Email,
string? PortraitUrl,
string Role,
IReadOnlyCollection<string> Permissions,
DateTimeOffset CreatedAt);
public record OrganizationDto(
Guid Id,
string Name,
Guid OwnerUserId,
IReadOnlyCollection<string> CurrentUserPermissions,
IReadOnlyCollection<OrganizationMemberDto> Members,
IReadOnlyCollection<WorkspaceDto> Workspaces,
DateTimeOffset CreatedAt)
{
public static OrganizationDto FromOrganization(
Organization organization,
IReadOnlyCollection<string> currentUserPermissions,
IReadOnlyCollection<OrganizationMemberDto>? members = null,
IReadOnlyCollection<WorkspaceDto>? workspaces = null)
{
return new OrganizationDto(
organization.Id,
organization.Name,
organization.OwnerUserId,
currentUserPermissions,
members ?? [],
workspaces ?? [],
organization.CreatedAt);
}
}

View File

@@ -0,0 +1,202 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Identity.Contracts;
namespace Socialize.Api.Modules.Organizations.Services;
public sealed class OrganizationAccessService(
AppDbContext dbContext)
{
public bool IsGlobalManager(ClaimsPrincipal user)
{
return user.IsInRole(KnownRoles.Administrator) || user.IsInRole(KnownRoles.Manager);
}
public async Task<IReadOnlyCollection<Guid>> GetAccessibleOrganizationIdsAsync(
ClaimsPrincipal user,
CancellationToken ct)
{
if (IsGlobalManager(user))
{
return await dbContext.Organizations
.Select(organization => organization.Id)
.ToArrayAsync(ct);
}
Guid userId = user.GetUserId();
Guid[] ownedOrganizationIds = await dbContext.Organizations
.Where(organization => organization.OwnerUserId == userId)
.Select(organization => organization.Id)
.ToArrayAsync(ct);
Guid[] memberOrganizationIds = await dbContext.OrganizationMemberships
.Where(membership => membership.UserId == userId)
.Select(membership => membership.OrganizationId)
.ToArrayAsync(ct);
return ownedOrganizationIds
.Concat(memberOrganizationIds)
.Distinct()
.ToArray();
}
public async Task<IReadOnlyCollection<Guid>> GetAccessibleWorkspaceIdsAsync(
ClaimsPrincipal user,
CancellationToken ct)
{
if (IsGlobalManager(user))
{
return await dbContext.Workspaces
.Select(workspace => workspace.Id)
.ToArrayAsync(ct);
}
Guid[] directWorkspaceIds = user.GetWorkspaceScopeIds().ToArray();
Guid[] organizationWorkspaceIds = await GetInheritedWorkspaceIdsAsync(user, OrganizationPermissions.AccessOwnedWorkspaces, ct);
return directWorkspaceIds
.Concat(organizationWorkspaceIds)
.Distinct()
.ToArray();
}
public async Task<bool> CanAccessOrganizationAsync(
ClaimsPrincipal user,
Guid organizationId,
CancellationToken ct)
{
if (IsGlobalManager(user))
{
return true;
}
Guid userId = user.GetUserId();
return await dbContext.Organizations.AnyAsync(
organization => organization.Id == organizationId && organization.OwnerUserId == userId,
ct)
|| await dbContext.OrganizationMemberships.AnyAsync(
membership => membership.OrganizationId == organizationId && membership.UserId == userId,
ct);
}
public async Task<bool> HasOrganizationPermissionAsync(
ClaimsPrincipal user,
Guid organizationId,
string permission,
CancellationToken ct)
{
if (IsGlobalManager(user))
{
return true;
}
Guid userId = user.GetUserId();
bool owner = await dbContext.Organizations.AnyAsync(
organization => organization.Id == organizationId && organization.OwnerUserId == userId,
ct);
if (owner)
{
return OrganizationPermissionRules.RoleHasPermission(OrganizationRoles.Owner, permission);
}
string[] roles = await dbContext.OrganizationMemberships
.Where(membership => membership.OrganizationId == organizationId && membership.UserId == userId)
.Select(membership => membership.Role)
.ToArrayAsync(ct);
return roles.Any(role => OrganizationPermissionRules.RoleHasPermission(role, permission));
}
public async Task<IReadOnlyCollection<string>> GetUserOrganizationPermissionsAsync(
ClaimsPrincipal user,
Guid organizationId,
CancellationToken ct)
{
if (IsGlobalManager(user))
{
return OrganizationPermissionRules.GetPermissionsForRole(OrganizationRoles.Owner);
}
Guid userId = user.GetUserId();
bool owner = await dbContext.Organizations.AnyAsync(
organization => organization.Id == organizationId && organization.OwnerUserId == userId,
ct);
if (owner)
{
return OrganizationPermissionRules.GetPermissionsForRole(OrganizationRoles.Owner);
}
string[] roles = await dbContext.OrganizationMemberships
.Where(membership => membership.OrganizationId == organizationId && membership.UserId == userId)
.Select(membership => membership.Role)
.ToArrayAsync(ct);
return roles
.SelectMany(OrganizationPermissionRules.GetPermissionsForRole)
.Distinct(StringComparer.Ordinal)
.OrderBy(permission => permission)
.ToArray();
}
public async Task<bool> HasInheritedWorkspacePermissionAsync(
ClaimsPrincipal user,
Guid workspaceId,
string permission,
CancellationToken ct)
{
if (IsGlobalManager(user))
{
return true;
}
Guid? organizationId = await dbContext.Workspaces
.Where(workspace => workspace.Id == workspaceId)
.Select(workspace => (Guid?)workspace.OrganizationId)
.SingleOrDefaultAsync(ct);
return organizationId.HasValue &&
await HasOrganizationPermissionAsync(user, organizationId.Value, permission, ct);
}
private async Task<Guid[]> GetInheritedWorkspaceIdsAsync(
ClaimsPrincipal user,
string permission,
CancellationToken ct)
{
Guid userId = user.GetUserId();
Guid[] ownedOrganizationIds = await dbContext.Organizations
.Where(organization => organization.OwnerUserId == userId)
.Select(organization => organization.Id)
.ToArrayAsync(ct);
List<Data.OrganizationMembership> memberships = await dbContext.OrganizationMemberships
.Where(membership => membership.UserId == userId)
.ToListAsync(ct);
Guid[] memberOrganizationIds = memberships
.Where(membership => OrganizationPermissionRules.RoleHasPermission(membership.Role, permission))
.Select(membership => membership.OrganizationId)
.ToArray();
Guid[] organizationIds = ownedOrganizationIds
.Concat(memberOrganizationIds)
.Distinct()
.ToArray();
if (organizationIds.Length == 0)
{
return [];
}
return await dbContext.Workspaces
.Where(workspace => organizationIds.Contains(workspace.OrganizationId))
.Select(workspace => workspace.Id)
.ToArrayAsync(ct);
}
}

View File

@@ -0,0 +1,50 @@
namespace Socialize.Api.Modules.Organizations.Services;
public static class OrganizationPermissionRules
{
public static IReadOnlyCollection<string> GetPermissionsForRole(string role)
{
return role switch
{
OrganizationRoles.Owner =>
[
OrganizationPermissions.ManageOrganizationSettings,
OrganizationPermissions.ManageOrganizationMembers,
OrganizationPermissions.CreateWorkspaces,
OrganizationPermissions.ManageWorkspaces,
OrganizationPermissions.ManageBilling,
OrganizationPermissions.ManageConnectors,
OrganizationPermissions.AccessOwnedWorkspaces,
],
OrganizationRoles.Admin =>
[
OrganizationPermissions.ManageOrganizationSettings,
OrganizationPermissions.ManageOrganizationMembers,
OrganizationPermissions.CreateWorkspaces,
OrganizationPermissions.ManageWorkspaces,
OrganizationPermissions.ManageConnectors,
OrganizationPermissions.AccessOwnedWorkspaces,
],
OrganizationRoles.BillingManager =>
[
OrganizationPermissions.ManageBilling,
OrganizationPermissions.AccessOwnedWorkspaces,
],
OrganizationRoles.ConnectorManager =>
[
OrganizationPermissions.ManageConnectors,
OrganizationPermissions.AccessOwnedWorkspaces,
],
OrganizationRoles.Member =>
[
OrganizationPermissions.AccessOwnedWorkspaces,
],
_ => [],
};
}
public static bool RoleHasPermission(string role, string permission)
{
return GetPermissionsForRole(role).Contains(permission, StringComparer.Ordinal);
}
}

View File

@@ -0,0 +1,12 @@
namespace Socialize.Api.Modules.Organizations.Services;
public static class OrganizationPermissions
{
public const string ManageOrganizationSettings = "ManageOrganizationSettings";
public const string ManageOrganizationMembers = "ManageOrganizationMembers";
public const string CreateWorkspaces = "CreateWorkspaces";
public const string ManageWorkspaces = "ManageWorkspaces";
public const string ManageBilling = "ManageBilling";
public const string ManageConnectors = "ManageConnectors";
public const string AccessOwnedWorkspaces = "AccessOwnedWorkspaces";
}

View File

@@ -0,0 +1,10 @@
namespace Socialize.Api.Modules.Organizations.Services;
public static class OrganizationRoles
{
public const string Owner = "Owner";
public const string Admin = "Admin";
public const string BillingManager = "BillingManager";
public const string ConnectorManager = "ConnectorManager";
public const string Member = "Member";
}

View File

@@ -6,6 +6,7 @@ public class Workspace
public required string Name { get; set; }
public required string Slug { get; set; }
public string? LogoUrl { get; set; }
public Guid OrganizationId { get; set; }
public Guid OwnerUserId { get; set; }
public required string TimeZone { get; set; }
public string ApprovalMode { get; set; } = "Required";

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Modules.Organizations.Data;
namespace Socialize.Api.Modules.Workspaces.Data;
@@ -22,7 +23,12 @@ public static class WorkspaceModelConfiguration
.ValueGeneratedOnAdd()
.HasDefaultValueSql("CURRENT_TIMESTAMP");
workspace.HasIndex(x => x.Slug).IsUnique();
workspace.HasIndex(x => x.OrganizationId);
workspace.HasIndex(x => x.OwnerUserId);
workspace.HasOne<Organization>()
.WithMany()
.HasForeignKey(x => x.OrganizationId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<WorkspaceInvite>(workspaceInvite =>

View File

@@ -47,7 +47,7 @@ public class ChangeWorkspaceLogoHandler(
return;
}
if (!accessScopeService.CanManageWorkspace(User, workspace.Id))
if (!await accessScopeService.CanManageWorkspaceAsync(User, workspace.Id, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -7,6 +7,7 @@ using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Workspaces.Handlers;
public record CreateWorkspaceRequest(
Guid OrganizationId,
string Name,
string Slug,
string TimeZone);
@@ -16,6 +17,7 @@ public class CreateWorkspaceRequestValidator
{
public CreateWorkspaceRequestValidator()
{
RuleFor(x => x.OrganizationId).NotEmpty();
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
RuleFor(x => x.Slug)
.NotEmpty()
@@ -38,12 +40,21 @@ public class CreateWorkspaceHandler(
public override async Task HandleAsync(CreateWorkspaceRequest request, CancellationToken ct)
{
if (!accessScopeService.IsManager(User))
if (!await accessScopeService.CanCreateWorkspaceAsync(User, request.OrganizationId, ct))
{
await SendForbiddenAsync(ct);
return;
}
bool organizationExists = await dbContext.Organizations
.AnyAsync(organization => organization.Id == request.OrganizationId, ct);
if (!organizationExists)
{
AddError(request => request.OrganizationId, "The selected organization does not exist.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
string normalizedName = request.Name.Trim();
string normalizedSlug = request.Slug.Trim().ToLowerInvariant();
string normalizedTimeZone = request.TimeZone.Trim();
@@ -61,6 +72,7 @@ public class CreateWorkspaceHandler(
Workspace workspace = new()
{
Id = Guid.NewGuid(),
OrganizationId = request.OrganizationId,
Name = normalizedName,
Slug = normalizedSlug,
OwnerUserId = User.GetUserId(),
@@ -71,18 +83,7 @@ public class CreateWorkspaceHandler(
dbContext.Workspaces.Add(workspace);
await dbContext.SaveChangesAsync(ct);
WorkspaceDto dto = new(
workspace.Id,
workspace.Name,
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.ApprovalMode,
workspace.SchedulePostsAutomaticallyOnApproval,
workspace.LockContentAfterApproval,
workspace.SendAutomaticApprovalReminders,
[],
workspace.CreatedAt);
WorkspaceDto dto = WorkspaceDto.FromWorkspace(workspace, []);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}

View File

@@ -43,7 +43,7 @@ public class CreateWorkspaceInviteHandler(
{
Guid workspaceId = Route<Guid>("workspaceId");
if (!accessScopeService.CanManageWorkspace(User, workspaceId))
if (!await accessScopeService.CanManageWorkspaceAsync(User, workspaceId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -29,7 +29,7 @@ public class GetWorkspaceInvitesHandler(
{
Guid workspaceId = Route<Guid>("workspaceId");
if (!accessScopeService.CanManageWorkspace(User, workspaceId))
if (!await accessScopeService.CanManageWorkspaceAsync(User, workspaceId, ct))
{
await SendForbiddenAsync(ct);
return;

View File

@@ -4,6 +4,7 @@ using System.Security.Claims;
using Socialize.Api.Data;
using Socialize.Api.Modules.Identity.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Workspaces.Data;
namespace Socialize.Api.Modules.Workspaces.Handlers;
@@ -12,6 +13,7 @@ public record WorkspaceMemberDto(
string DisplayName,
string Email,
string? PortraitUrl,
string RelationshipCategory,
IReadOnlyCollection<string> Roles);
public class GetWorkspaceMembersHandler(
@@ -29,12 +31,20 @@ public class GetWorkspaceMembersHandler(
{
Guid workspaceId = Route<Guid>("workspaceId");
if (!accessScopeService.CanManageWorkspace(User, workspaceId))
if (!await accessScopeService.CanManageWorkspaceAsync(User, workspaceId, ct))
{
await SendForbiddenAsync(ct);
return;
}
Workspace? workspace = await dbContext.Workspaces
.SingleOrDefaultAsync(candidate => candidate.Id == workspaceId, ct);
if (workspace is null)
{
await SendNotFoundAsync(ct);
return;
}
string workspaceClaimValue = workspaceId.ToString();
var users = await dbContext.Users
@@ -42,7 +52,11 @@ public class GetWorkspaceMembersHandler(
dbContext.UserClaims.Any(claim =>
claim.UserId == candidate.Id &&
claim.ClaimType == KnownClaims.WorkspaceScope &&
claim.ClaimValue == workspaceClaimValue))
claim.ClaimValue == workspaceClaimValue) ||
dbContext.OrganizationMemberships.Any(membership =>
membership.UserId == candidate.Id &&
membership.OrganizationId == workspace.OrganizationId) ||
candidate.Id == workspace.OwnerUserId)
.OrderBy(candidate => candidate.Lastname)
.ThenBy(candidate => candidate.Firstname)
.ThenBy(candidate => candidate.Email)
@@ -70,12 +84,19 @@ public class GetWorkspaceMembersHandler(
.ToArray(),
ct);
HashSet<Guid> organizationMemberUserIds = await dbContext.OrganizationMemberships
.Where(membership => membership.OrganizationId == workspace.OrganizationId)
.Select(membership => membership.UserId)
.ToHashSetAsync(ct);
organizationMemberUserIds.Add(workspace.OwnerUserId);
var members = users
.Select(candidate => new WorkspaceMemberDto(
candidate.Id,
BuildDisplayName(candidate),
candidate.Email ?? string.Empty,
candidate.PortraitUrl,
organizationMemberUserIds.Contains(candidate.Id) ? "Organization Member" : "External Collaborator",
rolesByUserId.GetValueOrDefault(candidate.Id) ?? Array.Empty<string>()))
.ToList();

View File

@@ -19,6 +19,7 @@ public record ApprovalStepConfigurationDto(
public record WorkspaceDto(
Guid Id,
Guid OrganizationId,
string Name,
string Slug,
string? LogoUrl,
@@ -28,7 +29,27 @@ public record WorkspaceDto(
bool LockContentAfterApproval,
bool SendAutomaticApprovalReminders,
IReadOnlyCollection<ApprovalStepConfigurationDto> ApprovalSteps,
DateTimeOffset CreatedAt);
DateTimeOffset CreatedAt)
{
public static WorkspaceDto FromWorkspace(
Workspace workspace,
IReadOnlyCollection<ApprovalStepConfigurationDto> approvalSteps)
{
return new WorkspaceDto(
workspace.Id,
workspace.OrganizationId,
workspace.Name,
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.ApprovalMode,
workspace.SchedulePostsAutomaticallyOnApproval,
workspace.LockContentAfterApproval,
workspace.SendAutomaticApprovalReminders,
approvalSteps,
workspace.CreatedAt);
}
}
internal class GetWorkspacesHandler(
AppDbContext dbContext,
@@ -43,13 +64,9 @@ internal class GetWorkspacesHandler(
public override async Task HandleAsync(CancellationToken ct)
{
var query = dbContext.Workspaces.AsQueryable();
if (!accessScopeService.IsManager(User))
{
var workspaceScopeIds = User.GetWorkspaceScopeIds();
query = query.Where(workspace => workspaceScopeIds.Contains(workspace.Id));
}
IReadOnlyCollection<Guid> accessibleWorkspaceIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
var query = dbContext.Workspaces
.Where(workspace => accessibleWorkspaceIds.Contains(workspace.Id));
var workspaceRows = await query
.OrderBy(workspace => workspace.Name)
@@ -71,18 +88,9 @@ internal class GetWorkspacesHandler(
.ToArray());
var workspaces = workspaceRows
.Select(workspace => new WorkspaceDto(
workspace.Id,
workspace.Name,
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.ApprovalMode,
workspace.SchedulePostsAutomaticallyOnApproval,
workspace.LockContentAfterApproval,
workspace.SendAutomaticApprovalReminders,
approvalStepsByWorkspaceId.GetValueOrDefault(workspace.Id) ?? Array.Empty<ApprovalStepConfigurationDto>(),
workspace.CreatedAt))
.Select(workspace => WorkspaceDto.FromWorkspace(
workspace,
approvalStepsByWorkspaceId.GetValueOrDefault(workspace.Id) ?? Array.Empty<ApprovalStepConfigurationDto>()))
.ToList();
await SendOkAsync(workspaces, ct);

View File

@@ -73,7 +73,7 @@ public class UpdateWorkspaceHandler(
return;
}
if (!accessScopeService.CanManageWorkspace(User, workspace.Id))
if (!await accessScopeService.CanManageWorkspaceAsync(User, workspace.Id, ct))
{
await SendForbiddenAsync(ct);
return;
@@ -154,18 +154,7 @@ public class UpdateWorkspaceHandler(
step.CreatedAt))
.ToListAsync(ct);
WorkspaceDto dto = new(
workspace.Id,
workspace.Name,
workspace.Slug,
workspace.LogoUrl,
workspace.TimeZone,
workspace.ApprovalMode,
workspace.SchedulePostsAutomaticallyOnApproval,
workspace.LockContentAfterApproval,
workspace.SendAutomaticApprovalReminders,
approvalSteps,
workspace.CreatedAt);
WorkspaceDto dto = WorkspaceDto.FromWorkspace(workspace, approvalSteps);
await SendOkAsync(dto, ct);
}

View File

@@ -17,6 +17,7 @@ using Socialize.Api.Modules.Feedback;
using Socialize.Api.Modules.Identity;
using Socialize.Api.Modules.Notifications;
using Socialize.Api.Modules.Campaigns;
using Socialize.Api.Modules.Organizations;
using Socialize.Api.Modules.Workspaces;
@@ -62,6 +63,7 @@ var postgresConnectionString = builder.Configuration.GetConnectionString("Postgr
builder.Services.AddAppData(postgresConnectionString);
builder.AddInfrastructureModule();
builder.AddIdentityModule();
builder.AddOrganizationsModule();
builder.AddWorkspaceModule();
builder.AddClientsModule();
builder.AddCampaignsModule();

View File

@@ -7,18 +7,6 @@ namespace Socialize.Tests.Approvals;
public class ApprovalWorkflowRulesTests
{
[Theory]
[InlineData(ApprovalModes.Optional, true)]
[InlineData(ApprovalModes.Required, true)]
[InlineData(ApprovalModes.None, false)]
[InlineData(ApprovalModes.MultiLevel, false)]
public void CanCreateSingleStepApprovalRequest_matches_basic_modes(string approvalMode, bool expected)
{
bool actual = ApprovalWorkflowRules.CanCreateSingleStepApprovalRequest(approvalMode);
Assert.Equal(expected, actual);
}
[Theory]
[InlineData(ApprovalModes.Required, true)]
[InlineData(ApprovalModes.MultiLevel, true)]

View File

@@ -0,0 +1,51 @@
using Socialize.Api.Modules.Organizations.Services;
namespace Socialize.Tests.Organizations;
public class OrganizationPermissionRulesTests
{
[Fact]
public void Owner_has_all_initial_organization_permissions()
{
IReadOnlyCollection<string> permissions = OrganizationPermissionRules.GetPermissionsForRole(OrganizationRoles.Owner);
Assert.Contains(OrganizationPermissions.ManageOrganizationSettings, permissions);
Assert.Contains(OrganizationPermissions.ManageOrganizationMembers, permissions);
Assert.Contains(OrganizationPermissions.CreateWorkspaces, permissions);
Assert.Contains(OrganizationPermissions.ManageWorkspaces, permissions);
Assert.Contains(OrganizationPermissions.ManageBilling, permissions);
Assert.Contains(OrganizationPermissions.ManageConnectors, permissions);
Assert.Contains(OrganizationPermissions.AccessOwnedWorkspaces, permissions);
}
[Fact]
public void Admin_does_not_receive_billing_permission_by_default()
{
IReadOnlyCollection<string> permissions = OrganizationPermissionRules.GetPermissionsForRole(OrganizationRoles.Admin);
Assert.Contains(OrganizationPermissions.ManageOrganizationSettings, permissions);
Assert.Contains(OrganizationPermissions.ManageOrganizationMembers, permissions);
Assert.Contains(OrganizationPermissions.CreateWorkspaces, permissions);
Assert.Contains(OrganizationPermissions.ManageWorkspaces, permissions);
Assert.Contains(OrganizationPermissions.ManageConnectors, permissions);
Assert.Contains(OrganizationPermissions.AccessOwnedWorkspaces, permissions);
Assert.DoesNotContain(OrganizationPermissions.ManageBilling, permissions);
}
[Theory]
[InlineData(OrganizationRoles.BillingManager, OrganizationPermissions.ManageBilling, true)]
[InlineData(OrganizationRoles.BillingManager, OrganizationPermissions.ManageConnectors, false)]
[InlineData(OrganizationRoles.ConnectorManager, OrganizationPermissions.ManageConnectors, true)]
[InlineData(OrganizationRoles.ConnectorManager, OrganizationPermissions.ManageBilling, false)]
[InlineData(OrganizationRoles.Member, OrganizationPermissions.AccessOwnedWorkspaces, true)]
[InlineData(OrganizationRoles.Member, OrganizationPermissions.ManageWorkspaces, false)]
public void RoleHasPermission_enforces_role_permission_mapping(
string role,
string permission,
bool expected)
{
bool actual = OrganizationPermissionRules.RoleHasPermission(role, permission);
Assert.Equal(expected, actual);
}
}

View File

@@ -15,7 +15,7 @@
}
handle {
try_files {path} /index.html
try_files {path} {path}/index.html /index.html
file_server
}
}

View File

@@ -31,7 +31,7 @@ Composition registers:
- web services and auth in `DependencyInjection.cs`
- infrastructure in `Infrastructure/DependencyInjection.cs`
- domain modules for Identity, Workspaces, Clients, Campaigns, ContentItems, Assets, Comments, Approvals, Notifications, and Feedback
- domain modules for Identity, Organizations, Workspaces, Clients, Campaigns, ContentItems, Assets, Comments, Approvals, Notifications, and Feedback
## Data Ownership
@@ -43,6 +43,10 @@ backend/src/Socialize.Api/Data/AppDbContext.cs
Workflow data is organized by module folders. Do not couple unrelated modules through ad hoc service calls; keep ownership boundaries explicit.
`Organization` is the SaaS account boundary for billing, subscription limits, organization-level users, connectors, data mappings, and workspace ownership.
`Workspace` is the brand/client workflow boundary. Each workspace belongs to exactly one organization and owns workspace-scoped workflow data such as channels, content items, assets, comments, approvals, and notifications.
## Frontend
```txt

View File

@@ -0,0 +1,221 @@
# Feature: Organizations
## Status
Ready for initial backend implementation.
## Goal
Define the SaaS account boundary above workspaces.
An `Organization` owns billing, subscriptions, usage limits, organization-level users, connectors, data mappings, and the workspaces used for brand/client workflow.
## User Stories
- As an agency owner, I want my company to own multiple brand workspaces so that billing and administration happen once.
- As an in-house brand team, I want my company to own its workspaces so that billing, users, and connectors are controlled centrally.
- As a professional user, I want one login that can access multiple organizations so that I can work for my employer and freelance clients from the same account.
- As an organization admin, I want to manage organization members so that company-level access can grant inherited workspace access.
- As an organization admin, I want external clients and collaborators to remain visible in workspace members without making them organization members.
- As a billing manager, I want to manage subscription and billing for the organization.
- As an organization admin, I want to configure connectors such as Google Drive once for the organization.
- As a workspace user, I want the existing workspace selector to stay central while still letting me switch organizations.
## Domain Model
### Organization
`Organization` is the SaaS account boundary.
An organization owns:
- billing profile
- subscription plan
- subscription limits
- organization-level users and roles
- workspaces
- connectors and integration credentials
- data mapping rules for connected systems
An organization can own many workspaces.
### Workspace
`Workspace` is the brand/client workflow boundary.
Each workspace:
- belongs to exactly one organization
- is not shared between organizations
- owns brand/client content workflow data
- owns configured social channels
- uses organization-level connectors
### Channel
`Channel` is a configured social destination inside a workspace, such as a Twitter/X account, YouTube channel, Facebook page, Instagram account, or newsletter destination.
Connector credentials for external systems are configured at the organization level in v1.
### User
`User` is a global login identity.
A user can have access relationships with multiple organizations and direct access to multiple workspaces. A user account is not owned by a single organization.
Example:
```txt
alex@example.com
-> Organization access: PepsiCo, Content Manager
-> Organization access: Alex Freelance LLC, Owner and Billing Manager
-> Workspace access: Client review workspace, External Reviewer
```
## Membership And Access
### Organization Membership
Organization membership grants organization-level permissions and inherited workspace permissions across all workspaces owned by the organization.
Organization-level permissions include:
- organization settings
- organization member management
- workspace creation and administration
- billing and subscription management
- connector and data mapping management
Only users with a billing manager permission can view or edit billing and subscription information.
### Workspace Membership
Workspace membership grants access to one workspace.
Workspace participants have a relationship category relative to the organization that owns the workspace:
- `Organization Member`
- `External Collaborator`
`Organization Member` means the user is part of the owning organization. The user may have access through organization membership, direct workspace membership, or both.
`External Collaborator` means the user is not part of the owning organization but has direct workspace access. This includes subcontractors, providers, clients, and external reviewers.
External collaborators can be invited directly to a workspace without becoming organization members.
Organization admins should still be able to see external collaborators when reviewing who has access to organization-owned data.
Workspace membership can override applicable inherited workspace permissions. Organization-only permissions such as billing are not overridden at the workspace level.
## Permission Rules
- Organization permissions are inherited by all owned workspaces when they apply to workspace behavior.
- Workspace-level overrides can reduce or expand workspace-specific permissions where that makes sense.
- Billing and subscription permissions live only at the organization level.
- Connector management lives only at the organization level in v1.
- External collaborators cannot access organization billing, subscription, or connector settings unless they are separately granted organization membership with those permissions.
## Connectors And Data Mappings
Connectors are configured only at the organization level in v1.
Examples:
- Google Drive connection
- connector credentials
- default Drive folder mapping rules
- organization-wide data import or linkage rules
Workspaces use organization-level connectors and mappings. Workspace-specific connector credentials are out of scope for v1.
## Subscription And Limits
Subscription plans and usage limits belong to the organization.
Potential limits include:
- number of workspaces
- number of organization members
- number of external collaborators
- channels per workspace or per organization
- storage or asset limits
- access to advanced workflow features
Exact plan limits are a billing/product task and are not defined here.
## Frontend Surface
The existing workspace selector remains the primary navigation context.
Add an organization switcher at the bottom of the workspace selector.
Organization settings use one explicit organization route:
```txt
/app/organizations/:organizationId/settings
```
The settings page should contain sections for:
- profile/settings
- members
- billing
- connections
- workspaces
Workspace-level screens remain centered on the selected workspace.
## Backend Expectations
- Backend feature code should follow existing module patterns under `backend/src/Socialize.Api/Modules`.
- Workspaces must require an owning organization.
- Organization APIs should return only organizations the current user can access.
- Workspace APIs must preserve workspace scoping and account for inherited organization permissions.
- New backend contracts require OpenAPI regeneration while the backend is running.
## Implementation Readiness
The initial implementation should proceed through the task files in `docs/TASKS/organizations/`.
Recommended order:
1. `001-organization-domain-foundation.md`
2. `002-organization-membership-permissions.md`
3. `003-organization-settings-ui.md`
4. `004-workspace-selector-organization-switcher.md`
Task 001 should establish the organization table, workspace ownership, current-user organization read APIs, and development bootstrap data. It should not attempt the full inherited permission model beyond enough access data to prove a user can access their organizations.
Task 002 should introduce organization memberships and explicit organization permissions before frontend settings or switcher work relies on permission-gated data.
Frontend tasks should start only after backend contracts have been regenerated into `shared/openapi/openapi.json` and `frontend/src/api/schema.d.ts`.
Initial backend routes:
```txt
GET /api/organizations
GET /api/organizations/{organizationId}
```
Workspace responses should include `organizationId` once workspaces are owned by organizations.
## Out Of Scope For Initial Implementation
- Preserving existing local data through migration. Development data can be wiped.
- Workspace sharing across organizations.
- Workspace-level connector credentials.
- Full billing provider integration.
- Final subscription packaging and pricing.
- Cross-organization workspace access inheritance.
## Done When
- [ ] Organization is documented as the SaaS account boundary.
- [ ] Workspace is documented as the brand/client workflow boundary.
- [ ] Workspaces belong to exactly one organization.
- [ ] Organization membership can grant inherited workspace access.
- [ ] Workspace membership supports direct access and overrides.
- [ ] External collaborators do not require organization membership.
- [ ] Billing permissions live at the organization level.
- [ ] Connectors and data mappings live at the organization level.
- [ ] The workspace selector includes an organization switcher.

View File

@@ -18,7 +18,9 @@ Allow workspace managers to invite teammates, clients, and providers into a work
## Domain Rules
- Workspace invites grant access to one workspace owned by one organization.
- Workspace invites belong to exactly one workspace.
- Invite acceptance does not automatically create organization membership.
- Invite email matching should use normalized email addresses.
- Pending invite tokens must be single-use and should expire.
- Accepted invites must grant the invited role and a workspace scope claim for the invite workspace.
@@ -28,6 +30,7 @@ Allow workspace managers to invite teammates, clients, and providers into a work
- Managers can create, list, cancel, and resend invites only for workspaces they can manage.
- Managers must not be able to create duplicate pending invites for the same normalized email in the same workspace.
- Invite acceptance must be auditable through stored status and timestamp changes.
- External collaborator invitees should remain visible in workspace members and organization-level access review views without receiving organization-level billing, connector, or subscription permissions.
## Proposed Statuses

View File

@@ -2,7 +2,7 @@
Socialize is a workflow application for social media content review, revision, approval, and publication readiness.
It is not a public social network. It is a workspace-based coordination tool for internal teams, providers, and client approvers.
It is not a public social network. It is an organization-owned, workspace-based coordination tool for internal teams, providers, and client approvers.
## Primary Users
@@ -16,8 +16,12 @@ It is not a public social network. It is a workspace-based coordination tool for
## Core Product Shape
- workspace-based account boundary
- agencies able to manage multiple workspaces
- organizations as the SaaS account, billing, subscription, and connector boundary
- workspaces as brand/client workflow boundaries owned by one organization
- agencies, in-house teams, and professional businesses able to manage multiple workspaces
- organization members able to inherit access across owned workspaces
- external collaborators able to access specific workspaces without organization membership
- channels configured inside brand/client workspaces
- content items as the main reviewable unit
- assets and revisions tracked against content items
- comments and approvals attached to the work itself
@@ -38,10 +42,11 @@ It is not a public social network. It is a workspace-based coordination tool for
- full direct publishing engine
- full DAM platform
- analytics suite
- billing or subscription flows
- full billing provider integration and pricing/package automation
## Current Sources
- `docs/product/vision.md`
- `docs/product/glossary.md`
- `docs/constraints.md`
- `docs/FEATURES/organizations.md`

View File

@@ -25,6 +25,7 @@ This folder contains the project documentation used to guide product, implementa
- [product/vision.md](/home/jbourdon/repos/social-media/docs/product/vision.md): product intent, users, scope, and priorities.
- [product/glossary.md](/home/jbourdon/repos/social-media/docs/product/glossary.md): canonical domain vocabulary.
- [constraints.md](/home/jbourdon/repos/social-media/docs/constraints.md): business and technical invariants.
- [FEATURES/organizations.md](/home/jbourdon/repos/social-media/docs/FEATURES/organizations.md): organization account boundary, membership, billing, connectors, and workspace ownership.
- [BACKLOG.md](/home/jbourdon/repos/social-media/docs/BACKLOG.md): deferred technical and product work.
## Archived

132
docs/SEO.md Normal file
View File

@@ -0,0 +1,132 @@
# SEO And Public Page Prerendering
Socialize is primarily a client-side app, but the public marketing pages are prerendered during the frontend build so crawlers can index static HTML.
## Public Indexed Routes
These routes are treated as public site pages:
- `/`
- `/product`
- `/pricing`
- `/blogs`
- `/guides`
Authenticated app routes under `/app/*` and auth utility routes such as `/login`, `/register`, `/forgot-password`, `/reset-password`, and `/verify-email` are excluded from indexing in `robots.txt`.
## Build Flow
The frontend build runs:
```bash
vite build
vite build --ssr src/entry-public-ssr.js --outDir dist-ssr
node scripts/prerender-public.mjs
node scripts/write-public-seo.mjs
```
This is wired into:
```bash
cd frontend
npm run build
```
The prerender step writes static HTML files such as:
```txt
frontend/dist/index.html
frontend/dist/product/index.html
frontend/dist/pricing/index.html
frontend/dist/blogs/index.html
frontend/dist/guides/index.html
```
The SEO generator writes:
```txt
frontend/dist/robots.txt
frontend/dist/sitemap.xml
```
## Production Domain
Set the public site URL when building for production:
```bash
cd frontend
VITE_PUBLIC_SITE_URL=https://your-domain.com npm run build
```
This value is used for:
- canonical URLs
- sitemap URLs
- the sitemap reference in `robots.txt`
If `VITE_PUBLIC_SITE_URL` is not set, the build falls back to `SITE_URL`, then `http://localhost:5173`.
## Files To Update
When adding, removing, or renaming public indexed pages, update all of these:
- `frontend/src/router/router.js`
- `frontend/src/entry-public-ssr.js`
- `frontend/scripts/prerender-public.mjs`
- `frontend/scripts/write-public-seo.mjs`
- page metadata in the public page component via `usePublicPageMeta`
Public page metadata helper:
```txt
frontend/src/features/landing/publicPageMeta.js
```
## Server Routing
Caddy is configured to serve prerendered directory indexes before falling back to the SPA:
```txt
try_files {path} {path}/index.html /index.html
```
Config file:
```txt
deploy/caddy/Caddyfile
```
This matters because `/product` should serve `dist/product/index.html`, not the SPA fallback `dist/index.html`.
## Validation
After changes:
```bash
cd frontend
VITE_PUBLIC_SITE_URL=https://your-domain.com npm run build
```
Check generated HTML:
```bash
grep -n "<title>" dist/product/index.html
grep -n "canonical" dist/product/index.html
grep -n "Social media content approval" dist/product/index.html
```
Check crawler files:
```bash
cat dist/robots.txt
cat dist/sitemap.xml
```
## Search Engine Setup
After deployment:
1. Confirm public routes return `200`.
2. Confirm `/robots.txt` and `/sitemap.xml` are served.
3. Submit the sitemap in Google Search Console.
4. Keep auth and app routes out of the sitemap unless they become public content.

View File

@@ -0,0 +1,17 @@
# Responsive Sidebar Default
## Goal
Collapse the authenticated app sidebar by default on smaller devices while preserving the existing desktop expanded default and manual toggle behavior.
## Relevant Files
- `frontend/src/App.vue`
- `frontend/src/layouts/main/AppSidebar.vue`
## Validation
```bash
cd frontend
npm run build
```

View File

@@ -0,0 +1,27 @@
# Task: Extract content detail approval stepper
## Feature
`docs/FEATURES/approval-workflow.md`
## Goal
Move the approval area in `ContentItemDetailView` into a small feature-owned component that can show approval requests and multi-level workflow steps as a vertical stepper.
## Scope
- Extract the approval panel from `frontend/src/features/content/views/ContentItemDetailView.vue`.
- Render approval requests or workflow steps as circles in a vertical column connected by a dashed line.
- Show approval state, expected approver, due date, and recorded decisions in a hover/focus popup.
- Remove manual approval request creation from the content detail UI and backend API.
- Record the single documented v1 approval decision directly as `Approved`; approval discussion belongs in the content comments thread.
- Align the backend submit-decision request so approval comments are not accepted on new decisions.
- Remove temporary test text from content views.
## Validation Commands
```bash
dotnet build backend/Socialize.slnx
dotnet test backend/Socialize.slnx
cd frontend && npm run build
```

View File

@@ -0,0 +1,79 @@
# Task: Organization domain foundation
## Feature
`docs/FEATURES/organizations.md`
## Goal
Add the backend foundation for organizations as the SaaS account boundary and make workspaces belong to an organization.
## Context
Current docs and code treat `Workspace` as the top-level boundary. The product model now requires `Organization` above workspace for billing, subscriptions, connectors, limits, and workspace ownership.
Existing local data does not need to be preserved.
## Scope
- Add an `Organizations` backend module or follow the existing ownership pattern if organization code belongs with `Workspaces`.
- Add an organization persistence model with `Id`, `Name`, `OwnerUserId`, and `CreatedAt`, matching local conventions.
- Require every workspace to belong to exactly one organization.
- Update workspace create/list/detail APIs to include organization ownership.
- Add current-user organization read APIs:
- `GET /api/organizations`
- `GET /api/organizations/{organizationId}`
- Add backend validation that users cannot access organizations they have no relationship with.
- Seed or development bootstrap data should create at least one organization and owned workspace when local development data is empty.
- Update OpenAPI after backend contracts change.
## Constraints
- Keep backend code under `backend/src/Socialize.Api`.
- Preserve FastEndpoints module structure.
- Do not implement billing provider integration in this task.
- Do not implement connector storage in this task.
- Do not implement full organization membership override behavior in this task.
- Do not implement organization settings UI in this task.
- Existing development data may be wiped; do not spend scope on compatibility migration behavior.
## Implementation Notes
- Place organization-owned backend code under `backend/src/Socialize.Api/Modules/Organizations`.
- Add `DbSet<Organization>` and `ConfigureOrganizationsModule()` to `AppDbContext`.
- Keep `Workspace.OwnerUserId` for the existing creator/owner convention unless a later task explicitly replaces it.
- Add a required `Workspace.OrganizationId` property and database index.
- The first implementation may grant organization access through `Organization.OwnerUserId == currentUserId` and existing manager/administrator access. Full organization membership belongs to task 002.
- `CreateWorkspaceRequest` should require `OrganizationId`; reject creation when the user cannot manage that organization.
- `WorkspaceDto` should include `OrganizationId`.
- Use tests for unauthorized organization detail access and workspace creation under an inaccessible organization.
## Likely Files
- `backend/src/Socialize.Api/Data/AppDbContext.cs`
- `backend/src/Socialize.Api/Modules/Organizations/**`
- `backend/src/Socialize.Api/Modules/Workspaces/**`
- `backend/src/Socialize.Api/Migrations/**`
- `backend/tests/Socialize.Tests/**`
- `shared/openapi/openapi.json`
- `frontend/src/api/schema.d.ts`
## Done When
- [x] Organization entity is persisted.
- [x] Workspace requires `OrganizationId`.
- [x] Workspace APIs expose organization ownership.
- [x] Current user can list accessible organizations.
- [x] Current user can get accessible organization details.
- [x] Unauthorized organization access is rejected.
- [x] Development seed data creates an organization with owned workspaces.
- [x] Backend build and tests pass.
- [x] OpenAPI and generated frontend schema are updated.
## Validation Commands
```bash
dotnet build backend/Socialize.slnx
dotnet test backend/Socialize.slnx
./scripts/update-openapi.sh
```

View File

@@ -0,0 +1,96 @@
# Task: Organization membership and inherited permissions
## Feature
`docs/FEATURES/organizations.md`
## Goal
Model organization-level memberships and inherited workspace permissions with workspace-level overrides.
## Context
Users have global accounts. A user can have rights in multiple organizations and direct access to individual workspaces. Organization membership grants company-level access and inherited workspace permissions. Workspace membership can grant direct access or override workspace-specific inherited permissions.
## Scope
- Add organization membership persistence.
- Add organization-level roles or permissions for:
- organization owner/admin
- organization member management
- workspace creation/administration
- billing manager
- connector manager
- Define how organization permissions map to inherited workspace permissions.
- Preserve workspace participant relationship categories: `Organization Member` and `External Collaborator`.
- Allow workspace memberships to override applicable inherited workspace permissions.
- Ensure billing and connector permissions remain organization-level only.
- Update access checks used by workspace APIs to consider inherited organization permissions.
- Add tests for inherited access, direct workspace access, external collaborator access, and override behavior.
## Constraints
- Do not implement billing pages or billing provider integration in this task.
- Do not implement connector APIs in this task.
- Do not remove direct workspace membership support.
- External collaborators must not become organization members automatically.
- Keep permission names explicit; avoid magic strings where local patterns provide constants.
## Permission Model
Use explicit constants in the Organizations module rather than raw strings in handlers.
Initial organization permissions:
- `ManageOrganizationSettings`
- `ManageOrganizationMembers`
- `CreateWorkspaces`
- `ManageWorkspaces`
- `ManageBilling`
- `ManageConnectors`
- `AccessOwnedWorkspaces`
Initial organization roles should map to permissions in code:
- `Owner`: all organization permissions.
- `Admin`: organization settings, organization members, workspace creation, workspace administration, connector management, and owned workspace access. Billing is not included unless explicitly assigned.
- `BillingManager`: billing and owned workspace access.
- `ConnectorManager`: connector management and owned workspace access.
- `Member`: owned workspace access only.
Workspace-specific permissions may be overridden at the workspace level after inherited organization access is resolved. Billing and connector permissions must never be granted from workspace-level overrides.
Direct workspace members who are not organization members should be labeled `External Collaborator` in workspace membership responses. Organization members with inherited or direct workspace access should be labeled `Organization Member`.
## Implementation Notes
- Add an `OrganizationMembership` persistence model with `OrganizationId`, `UserId`, role/permission data, and `CreatedAt`.
- Prefer a small Organizations access service for organization access checks and inherited workspace permission calculation instead of adding ad hoc queries to every handler.
- Update JWT claims only if a task proves claims are needed; permission checks can query current database state first.
- Preserve existing global Identity roles while introducing organization-scoped roles. Do not reuse global `manager`, `client`, or `provider` roles as organization roles.
- Add unit tests for role-to-permission mapping and handler/integration tests for access rejection where existing test infrastructure supports it.
## Likely Files
- `backend/src/Socialize.Api/Modules/Organizations/**`
- `backend/src/Socialize.Api/Modules/Workspaces/**`
- `backend/src/Socialize.Api/Modules/Identity/**`
- `backend/src/Socialize.Api/Data/AppDbContext.cs`
- `backend/tests/Socialize.Tests/**`
## Done When
- [x] Organization memberships are persisted.
- [x] Organization roles/permissions include billing manager.
- [x] Organization-level access can grant inherited access to owned workspaces.
- [x] Direct workspace-only external collaborators remain supported.
- [x] Workspace-level overrides apply to workspace-specific permissions.
- [x] Billing and connector permissions cannot be granted through workspace overrides.
- [ ] Backend tests cover inherited, direct, external collaborator, and override access paths.
## Validation Commands
```bash
dotnet build backend/Socialize.slnx
dotnet test backend/Socialize.slnx
```

View File

@@ -0,0 +1,65 @@
# Task: Organization settings UI shell
## Feature
`docs/FEATURES/organizations.md`
## Goal
Add an organization-level settings page with sections for profile, members, billing access, connections, and owned workspaces.
## Context
Workspace screens remain the primary working context. Organization SaaS account administration lives under one explicit settings route: `/app/organizations/:organizationId/settings`.
## Scope
- Add the organization settings route:
- `/app/organizations/:organizationId/settings`
- Add feature-owned frontend code under `frontend/src/features/organizations/`.
- Load organization details from the backend.
- Show a settings page with sections for:
- profile/settings
- members and their roles/permissions
- billing
- connections
- owned workspaces
- Show the billing section only to users with billing manager permission.
- Show the connections section only to users with connector manager permission.
- Show the owned workspaces section to users with organization workspace administration access.
- Add English and French locale strings.
## Constraints
- Do not implement billing provider integration in this task.
- Do not implement real connection authorization flows in this task.
- Do not replace the existing workspace selector in this task.
- Frontend runtime config must flow through `frontend/src/config.js` if new runtime config is needed.
- Use the shared Axios API client in `frontend/src/plugins/api.js`.
## Likely Files
- `frontend/src/router/router.js`
- `frontend/src/features/organizations/**`
- `frontend/src/layouts/main/**`
- `frontend/src/locales/en.json`
- `frontend/src/locales/fr.json`
## Done When
- [ ] Organization settings route exists.
- [ ] Organization details load for accessible organizations.
- [ ] Organization settings page has sections for profile, members, billing, connections, and workspaces.
- [ ] Organization members are visible to permitted users.
- [ ] Billing section is permission-gated to billing managers.
- [ ] Connections section is permission-gated to connector managers.
- [ ] Owned workspaces section is visible to permitted organization users.
- [ ] UI strings exist in English and French.
- [ ] Frontend build passes.
## Validation Commands
```bash
cd frontend
npm run build
```

View File

@@ -0,0 +1,57 @@
# Task: Workspace selector organization switcher
## Feature
`docs/FEATURES/organizations.md`
## Goal
Keep the existing workspace selector as the primary context selector and add an organization switcher at the bottom.
## Context
The current workspace selector UX is good. The missing concept is that workspaces belong to organizations. Users may have access to multiple organizations and different workspaces under each one.
## Scope
- Load the current user's accessible organizations.
- Show the current organization at the bottom of the existing workspace selector.
- Allow users to switch organizations.
- Scope or group listed workspaces by the selected organization.
- Preserve current workspace selection behavior where possible.
- When switching organizations, select a sensible workspace for that organization or route to an organization/workspace selection state.
- Provide links from the organization switcher to organization settings routes when the user has access.
- Add English and French locale strings.
## Constraints
- Do not redesign the authenticated app shell.
- Do not turn the selector into a marketing or onboarding page.
- Preserve workspace as the primary day-to-day app context.
- Preserve route-level auth and role checks.
## Likely Files
- `frontend/src/layouts/main/**`
- `frontend/src/features/workspaces/**`
- `frontend/src/features/organizations/**`
- `frontend/src/stores/**`
- `frontend/src/locales/en.json`
- `frontend/src/locales/fr.json`
## Done When
- [ ] Workspace selector shows the active organization.
- [ ] Users can switch organizations from the selector.
- [ ] Workspace list reflects the selected organization.
- [ ] Organization settings links appear only when permitted.
- [ ] Existing workspace switching behavior remains usable.
- [ ] UI strings exist in English and French.
- [ ] Frontend build passes.
## Validation Commands
```bash
cd frontend
npm run build
```

View File

@@ -11,9 +11,14 @@ These are cross-cutting rules for the current product and codebase. They are int
## Domain Constraints
- `Workspace` is the top-level scoping boundary.
- An agency may manage multiple workspaces.
- `Organization` is the SaaS account, billing, subscription, connector, and workspace ownership boundary.
- `Workspace` is the brand/client workflow boundary.
- A workspace belongs to exactly one organization and is not shared between organizations.
- `ContentItem` belongs to a workspace scope.
- Channels are configured inside a workspace.
- Organization-level membership can grant inherited access to all workspaces owned by the organization.
- Workspace-level membership can grant direct workspace access and override applicable inherited workspace permissions.
- `External Collaborator` workspace participants can have workspace access without organization membership.
- Comments, approvals, assets, and notifications must remain traceable to the underlying workflow entity they relate to.
## Backend Constraints

View File

@@ -2,28 +2,67 @@
Use these terms consistently in product docs, specs, UI copy, and code discussions.
## Workspace
## Organization
Top-level working container for a client account.
SaaS account boundary that owns billing, subscription, limits, organization-level users, connectors, data mappings, and workspaces.
An agency may manage multiple workspaces.
An organization may be an agency, an in-house brand company, or a professional's business account.
Use:
- ownership boundary
- membership boundary
- scoping boundary for content items, assets, comments, approvals, and notifications
- billing and subscription boundary
- ownership boundary for workspaces
- connector and integration configuration boundary
- organization-level membership and permission boundary
Do not use as:
- synonym for a brand workspace
- synonym for a single human user account
- synonym for an external reviewer
## Workspace
Brand, client, or operating workspace where content workflow happens.
Each workspace belongs to exactly one organization and is not shared between organizations.
Use:
- brand/client workflow boundary
- scoping boundary for content items, assets, comments, approvals, and notifications
- channel configuration boundary
Do not use as:
- top-level SaaS account boundary
- billing or subscription boundary
- synonym for internal role
- synonym for team member role
## Organization Member
User access relationship at the organization level.
Organization membership can grant organization-level permissions and inherited access to all workspaces owned by that organization.
## Workspace Member
User access relationship at the workspace level.
Workspace membership grants direct access to one workspace and can classify the user's relationship to the owning organization as `Organization Member` or `External Collaborator`.
An `Organization Member` is part of the organization that owns the workspace. They may have access through organization membership, direct workspace membership, or both.
An `External Collaborator` is not part of the owning organization but has direct workspace access. This includes subcontractors, providers, clients, and external reviewers.
Workspace membership can override applicable inherited workspace permissions.
## Agency
Operating organization managing one or more workspaces.
Type of organization that manages one or more brand/client workspaces for customers.
An agency is above the workspace level in the business model.
Use `Organization` for the product model unless specifically describing an agency customer.
## Client
@@ -106,6 +145,8 @@ Examples:
Specific destination, account, handle, page, or feed within a network.
Channels are configured inside a workspace. The connector or integration credentials used to access external systems are owned by the organization.
Examples:
- `@MyBrand` on Instagram

View File

@@ -8,7 +8,7 @@ Active
`Socialize` is a workflow application for social media content review, revision, approval, and publication readiness.
It is not a public social network. It is a workspace-based coordination tool for internal teams, providers, and client approvers.
It is not a public social network. It is an organization-owned, workspace-based coordination tool for internal teams, providers, and client approvers.
## Problem
@@ -38,8 +38,11 @@ Provide one system of workflow for drafting, revising, reviewing, approving, and
## Core Product Shape
- workspace-based account boundary
- agencies able to manage multiple workspaces
- organizations as the SaaS account, billing, subscription, and connector boundary
- workspaces as brand/client workflow boundaries owned by one organization
- agencies, in-house teams, and professional businesses able to manage multiple workspaces
- organization members able to inherit access across owned workspaces
- external collaborators able to access specific workspaces without organization membership
- content items as the main reviewable unit
- assets and revisions tracked against content items
- comments and approvals attached to the work itself
@@ -60,7 +63,7 @@ Provide one system of workflow for drafting, revising, reviewing, approving, and
- not a full publishing engine in version 1
- not a full DAM platform in version 1
- not a full analytics product in version 1
- not a billing/subscription product in version 1
- not a full billing provider or pricing/package automation product in version 1
## MVP Scope
@@ -86,7 +89,7 @@ Version 1 should focus on approval workflow rather than direct publishing.
- direct social publishing
- advanced third-party synchronization
- analytics suite
- customer billing flows
- full customer billing provider integration and pricing/package automation
## Current Strategic Assumptions

View File

@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "vite build && vite build --ssr src/entry-public-ssr.js --outDir dist-ssr && node scripts/prerender-public.mjs && node scripts/write-public-seo.mjs",
"preview": "vite preview",
"api:schema": "node scripts/fetch-openapi.mjs",
"api:types": "openapi-typescript ../shared/openapi/openapi.json -o src/api/schema.d.ts",

View File

@@ -0,0 +1,34 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const publicRoutes = ['/', '/product', '/pricing', '/blogs', '/guides'];
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const distDir = resolve(rootDir, 'dist');
const ssrEntry = resolve(rootDir, 'dist-ssr/entry-public-ssr.js');
const templatePath = resolve(distDir, 'index.html');
const template = await readFile(templatePath, 'utf8');
const { render } = await import(pathToFileURL(ssrEntry));
const baseTemplate = template.replace('<title>Socialize</title>', '');
function outputPathForRoute(route) {
if (route === '/') {
return resolve(distDir, 'index.html');
}
return resolve(distDir, route.replace(/^\//, ''), 'index.html');
}
for (const route of publicRoutes) {
const { appHtml, headTags } = await render(route);
const html = baseTemplate
.replace('</head>', `${headTags.headTags}\n</head>`)
.replace('<div id="app"></div>', `<div id="app">${appHtml}</div>`);
const outputPath = outputPathForRoute(route);
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, html);
console.log(`Prerendered ${route}`);
}

View File

@@ -0,0 +1,61 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
const publicRoutes = [
{ path: '/', changefreq: 'weekly', priority: '1.0' },
{ path: '/product', changefreq: 'weekly', priority: '0.8' },
{ path: '/pricing', changefreq: 'monthly', priority: '0.7' },
{ path: '/blogs', changefreq: 'weekly', priority: '0.6' },
{ path: '/guides', changefreq: 'weekly', priority: '0.6' },
];
const disallowedRoutes = [
'/app/',
'/login',
'/register',
'/forgot-password',
'/reset-password',
'/verify-email',
];
const siteUrl = (process.env.VITE_PUBLIC_SITE_URL ?? process.env.SITE_URL ?? 'http://localhost:5173')
.replace(/\/$/, '');
const distDir = resolve(process.cwd(), 'dist');
function absoluteUrl(path) {
return new URL(path, `${siteUrl}/`).toString();
}
const robots = [
'User-agent: *',
'Allow: /',
...disallowedRoutes.map(route => `Disallow: ${route}`),
'',
`Sitemap: ${absoluteUrl('/sitemap.xml')}`,
'',
].join('\n');
const sitemapEntries = publicRoutes
.map(route => [
' <url>',
` <loc>${absoluteUrl(route.path)}</loc>`,
` <changefreq>${route.changefreq}</changefreq>`,
` <priority>${route.priority}</priority>`,
' </url>',
].join('\n'))
.join('\n');
const sitemap = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
sitemapEntries,
'</urlset>',
'',
].join('\n');
await mkdir(distDir, { recursive: true });
await writeFile(resolve(distDir, 'robots.txt'), robots);
await writeFile(resolve(distDir, 'sitemap.xml'), sitemap);
console.log(`Wrote public SEO files for ${siteUrl}`);

View File

@@ -1,28 +1,25 @@
<template>
<v-app>
<div class="shell-container">
<app-bar
:show-brand="true"
:collapse-brand="showsAppSidebar && !isSidebarExpanded"
/>
<template v-if="showsAppSidebar">
<div class="shell-sidebar-wrap">
<app-sidebar :is-expanded="isSidebarExpanded" />
<button
class="sidebar-boundary-toggle"
type="button"
@click="isSidebarExpanded = !isSidebarExpanded"
>
<v-icon :icon="isSidebarExpanded ? mdiChevronLeft : mdiChevronRight" />
</button>
</div>
</template>
<div
class="shell-main"
:class="{ 'shell-main-app': showsAppSidebar }"
>
<template v-if="showsAppSidebar">
<div class="shell-sidebar-wrap">
<app-sidebar :is-expanded="isSidebarExpanded" />
<button
class="sidebar-boundary-toggle"
type="button"
@click="isSidebarExpanded = !isSidebarExpanded"
>
<v-icon :icon="isSidebarExpanded ? mdiChevronLeft : mdiChevronRight" />
</button>
</div>
</template>
<app-bar v-if="showsAppSidebar" />
<div class="shell-view">
<router-view></router-view>
@@ -37,6 +34,7 @@
<script async setup>
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useDisplay } from 'vuetify';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
import AppBar from '@/layouts/main/AppBar.vue';
@@ -45,7 +43,9 @@
const route = useRoute();
const authStore = useAuthStore();
const isSidebarExpanded = ref(true);
const { smAndDown } = useDisplay();
const defaultSidebarExpanded = computed(() => !smAndDown.value);
const isSidebarExpanded = ref(defaultSidebarExpanded.value);
const showsAppSidebar = computed(() =>
authStore.isAuthenticated && route.path.startsWith('/app')
@@ -57,13 +57,13 @@
return;
}
isSidebarExpanded.value = true;
isSidebarExpanded.value = defaultSidebarExpanded.value;
}, { immediate: true });
</script>
<style scoped>
.shell-container {
@apply min-h-screen flex flex-col;
@apply min-h-screen flex flex-row;
@apply w-full font-sans;
background:
radial-gradient(circle at top left, rgba(255, 174, 94, 0.18), transparent 28%),
@@ -73,19 +73,19 @@
}
.shell-main {
@apply relative flex flex-1 flex-col;
@apply relative flex min-w-0 flex-1 flex-col;
}
.shell-main-app {
@apply md:flex-row md:items-start;
@apply min-h-screen;
}
.shell-sidebar-wrap {
@apply relative flex-shrink-0;
@apply sticky top-0 z-30 h-screen flex-shrink-0;
}
.sidebar-boundary-toggle {
@apply absolute left-full top-8 z-10 flex h-10 w-10 -translate-x-1/2 items-center justify-center rounded-full border transition-colors;
@apply absolute left-full top-8 z-40 flex h-10 w-10 -translate-x-1/2 items-center justify-center rounded-full border transition-colors;
background: rgba(255, 250, 242, 0.98);
border-color: rgba(23, 32, 51, 0.12);
color: #44516a;

View File

@@ -100,6 +100,38 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/organizations/{organizationId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["SocializeApiModulesOrganizationsHandlersGetOrganizationHandler"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/organizations": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["SocializeApiModulesOrganizationsHandlersGetOrganizationsHandler"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/notifications": {
parameters: {
query?: never;
@@ -829,7 +861,7 @@ export interface paths {
};
get: operations["SocializeApiModulesApprovalsHandlersGetApprovalsHandler"];
put?: never;
post: operations["SocializeApiModulesApprovalsHandlersCreateApprovalRequestHandler"];
post?: never;
delete?: never;
options?: never;
head?: never;
@@ -878,6 +910,8 @@ export interface components {
SocializeApiModulesWorkspacesHandlersWorkspaceDto: {
/** Format: guid */
id?: string;
/** Format: guid */
organizationId?: string;
name?: string;
slug?: string;
logoUrl?: string | null;
@@ -906,6 +940,8 @@ export interface components {
createdAt?: string;
};
SocializeApiModulesWorkspacesHandlersCreateWorkspaceRequest: {
/** Format: guid */
organizationId: string;
name: string;
slug: string;
timeZone: string;
@@ -932,6 +968,7 @@ export interface components {
displayName?: string;
email?: string;
portraitUrl?: string | null;
relationshipCategory?: string;
roles?: string[];
};
SocializeApiModulesWorkspacesHandlersUpdateWorkspaceRequest: {
@@ -952,6 +989,29 @@ export interface components {
/** Format: int32 */
requiredApproverCount?: number;
};
SocializeApiModulesOrganizationsHandlersOrganizationDto: {
/** Format: guid */
id?: string;
name?: string;
/** Format: guid */
ownerUserId?: string;
currentUserPermissions?: string[];
members?: components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationMemberDto"][];
workspaces?: components["schemas"]["SocializeApiModulesWorkspacesHandlersWorkspaceDto"][];
/** Format: date-time */
createdAt?: string;
};
SocializeApiModulesOrganizationsHandlersOrganizationMemberDto: {
/** Format: guid */
userId?: string;
displayName?: string;
email?: string;
portraitUrl?: string | null;
role?: string;
permissions?: string[];
/** Format: date-time */
createdAt?: string;
};
SocializeApiModulesNotificationsHandlersNotificationEventDto: {
/** Format: guid */
id?: string;
@@ -1479,22 +1539,9 @@ export interface components {
/** Format: date-time */
createdAt?: string;
};
SocializeApiModulesApprovalsHandlersCreateApprovalRequestRequest: {
/** Format: guid */
workspaceId: string;
/** Format: guid */
contentItemId: string;
stage: string;
reviewerName: string;
/** Format: email */
reviewerEmail: string;
/** Format: date-time */
dueAt?: string | null;
};
SocializeApiModulesApprovalsHandlersGetApprovalsRequest: Record<string, never>;
SocializeApiModulesApprovalsHandlersSubmitApprovalDecisionRequest: {
decision: string;
comment?: string | null;
reviewerName?: string | null;
/** Format: email */
reviewerEmail?: string | null;
@@ -1778,6 +1825,62 @@ export interface operations {
};
};
};
SocializeApiModulesOrganizationsHandlersGetOrganizationHandler: {
parameters: {
query?: never;
header?: never;
path: {
organizationId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesOrganizationsHandlersGetOrganizationsHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesOrganizationsHandlersOrganizationDto"][];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesNotificationsHandlersGetNotificationsHandler: {
parameters: {
query?: {
@@ -3535,46 +3638,6 @@ export interface operations {
};
};
};
SocializeApiModulesApprovalsHandlersCreateApprovalRequestHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SocializeApiModulesApprovalsHandlersCreateApprovalRequestRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesApprovalsHandlersApprovalRequestDto"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesApprovalsHandlersSubmitApprovalDecisionHandler: {
parameters: {
query?: never;

View File

@@ -0,0 +1,59 @@
import { createSSRApp, h } from 'vue';
import { createMemoryHistory, createRouter, RouterView } from 'vue-router';
import { createHead, renderHeadToString } from '@vueuse/head';
import { renderToString } from '@vue/server-renderer';
import { createI18n } from 'vue-i18n';
import en from '@/locales/en.json';
import fr from '@/locales/fr.json';
import Landing from '@/features/landing/views/Landing.vue';
import ProductPage from '@/features/landing/views/ProductPage.vue';
import PricingPage from '@/features/landing/views/PricingPage.vue';
import BlogsPage from '@/features/landing/views/BlogsPage.vue';
import GuidesPage from '@/features/landing/views/GuidesPage.vue';
import './assets/main.css';
const publicRoutes = [
{ path: '/', component: Landing },
{ path: '/product', component: ProductPage },
{ path: '/pricing', component: PricingPage },
{ path: '/blogs', component: BlogsPage },
{ path: '/guides', component: GuidesPage },
{ path: '/login', component: { render: () => null } },
{ path: '/register', component: { render: () => null } },
];
export async function render(routePath) {
const router = createRouter({
history: createMemoryHistory(),
routes: publicRoutes,
});
const head = createHead();
const i18n = createI18n({
legacy: false,
fallbackLocale: 'en',
messages: {
en,
fr,
},
});
const app = createSSRApp({
render: () => h(RouterView),
});
app.use(router);
app.use(head);
app.use(i18n);
await router.push(routePath);
await router.isReady();
const appHtml = await renderToString(app);
const headTags = await renderHeadToString(head);
return {
appHtml,
headTags,
};
}

View File

@@ -45,23 +45,32 @@ export function useFacebookLogin() {
const loginWithFacebook = () => {
if (!isSdkLoaded.value) {
console.error("Facebook SDK non encore chargé !");
return;
return Promise.reject(new Error("Facebook SDK is not loaded"));
}
window.FB.login(
(response) => {
if (response.authResponse) {
console.log("Utilisateur connecté :", response);
const authStore = useAuthStore();
authStore.loginWithFacebook(response.authResponse);
} else {
console.log("Connexion annulée ou échouée.");
return new Promise((resolve, reject) => {
window.FB.login(
async (response) => {
if (!response.authResponse) {
console.log("Connexion annulée ou échouée.");
reject(new Error("Facebook login was cancelled or failed"));
return;
}
try {
console.log("Utilisateur connecté :", response);
const authStore = useAuthStore();
const result = await authStore.loginWithFacebook(response.authResponse);
resolve(result);
} catch (error) {
reject(error);
}
},
{
scope: "public_profile,email"
}
},
{
scope: "public_profile,email"
}
);
);
});
};
onMounted(() => {

View File

@@ -1,94 +1,116 @@
<template>
<div class="flex min-h-full w-full items-center justify-center p-4">
<div class="flex w-full max-w-[512px] flex-col gap-10">
<h1 class="login-text text-center text-2xl font-bold">
{{ t('title') }}
</h1>
<div class="login-page">
<div class="login-wrap">
<router-link
class="login-brand"
to="/"
>
<span class="login-brand-mark">S</span>
<span class="login-brand-text">Socialize</span>
</router-link>
<div class="flex flex-col gap-4">
<google-login
:callback="googleCallback"
popup-type="TOKEN"
>
<button class="secondary">
<section class="login-card">
<h1 class="login-text text-center text-2xl font-bold">
{{ t('title') }}
</h1>
<div class="flex flex-col gap-4">
<google-login
:callback="googleCallback"
popup-type="TOKEN"
>
<button class="secondary">
<v-icon
:icon="mdiGoogle"
class="mr-2"
/>
{{ t('continueWithGoogle') }}
</button>
</google-login>
<button
class="secondary"
type="button"
@click="handleFacebookLogin"
>
<v-icon
:icon="mdiGoogle"
:icon="mdiFacebook"
class="mr-2"
/>
{{ t('continueWithGoogle') }}
{{ t('continueWithFacebook') }}
</button>
</google-login>
</div>
<div class="my-4 flex items-center">
<div class="h-px grow bg-gray-200"></div>
<span class="px-3 text-sm font-semibold uppercase text-gray-300">{{ t('orContinueWith') }}</span>
<div class="h-px grow bg-gray-200"></div>
</div>
<!-- Add email/password form -->
<v-form @submit.prevent="handleLocalLogin">
<div class="flex flex-col gap-4">
<v-text-field
v-model="email"
:label="t('email')"
required
type="email"
></v-text-field>
<v-text-field
v-model="password"
:label="t('password')"
:type="showPassword ? 'text' : 'password'"
required
>
<template v-slot:append-inner>
<v-icon
:icon="showPassword ? mdiEyeOff : mdiEye"
class="visibility-toggle"
size="small"
@click="showPassword = !showPassword"
/>
</template>
</v-text-field>
<v-btn
block
color="primary"
type="submit"
>
{{ t('signIn') }}
</v-btn>
<div class="text-center">
<a
class="cursor-pointer text-sm text-blue-500"
@click="forgotPassword"
>
{{ t('forgotPassword') }}
</a>
</div>
<div class="mt-2 text-center">
<a
class="cursor-pointer text-sm text-blue-500"
@click="resendVerification"
>
{{ t('resendVerification') }}
</a>
</div>
<div class="mt-4 text-center">
{{ t('noAccount') }}
<router-link
class="text-blue-500"
to="/register"
>
{{ t('register') }}
</router-link>
</div>
</div>
</v-form>
<div class="my-4 flex items-center">
<div class="h-px grow bg-gray-200"></div>
<span class="px-3 text-sm font-semibold uppercase text-gray-300">{{ t('orContinueWith') }}</span>
<div class="h-px grow bg-gray-200"></div>
</div>
<!-- Add email/password form -->
<v-form @submit.prevent="handleLocalLogin">
<div class="flex flex-col gap-4">
<v-text-field
v-model="email"
:label="t('email')"
required
type="email"
></v-text-field>
<v-text-field
v-model="password"
:label="t('password')"
:type="showPassword ? 'text' : 'password'"
required
>
<template v-slot:append-inner>
<v-icon
:icon="showPassword ? mdiEyeOff : mdiEye"
class="visibility-toggle"
size="small"
@click="showPassword = !showPassword"
/>
</template>
</v-text-field>
<v-btn
block
color="primary"
type="submit"
>
{{ t('signIn') }}
</v-btn>
<div class="text-center">
<a
class="cursor-pointer text-sm text-blue-500"
@click="forgotPassword"
>
{{ t('forgotPassword') }}
</a>
</div>
<div class="mt-2 text-center">
<a
class="cursor-pointer text-sm text-blue-500"
@click="resendVerification"
>
{{ t('resendVerification') }}
</a>
</div>
<div class="mt-4 text-center">
{{ t('noAccount') }}
<router-link
class="text-blue-500"
to="/register"
>
{{ t('register') }}
</router-link>
</div>
</div>
</v-form>
</section>
</div>
<!-- Error notification -->
@@ -105,13 +127,15 @@
import { ref } from 'vue';
import { GoogleLogin } from 'vue3-google-login';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useFacebookLogin } from '@/features/auth/composables/useFacebookLogin.js';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { mdiEye, mdiEyeOff, mdiGoogle } from '@mdi/js';
import { mdiEye, mdiEyeOff, mdiFacebook, mdiGoogle } from '@mdi/js';
const { t } = useI18n();
const router = useRouter();
const authStore = useAuthStore();
const { loginWithFacebook } = useFacebookLogin();
const email = ref('');
const password = ref('');
@@ -149,6 +173,20 @@
}
}
async function handleFacebookLogin() {
try {
const response = await loginWithFacebook();
if (response === true) {
await router.push(props.returnUrl);
} else {
errorSnackBar.value = true;
}
} catch (error) {
console.error('Facebook login failed:', error);
errorSnackBar.value = true;
}
}
function forgotPassword() {
router.push('/forgot-password');
}
@@ -159,6 +197,41 @@
</script>
<style scoped>
.login-page {
@apply flex min-h-screen w-full items-stretch justify-center sm:items-center sm:p-4;
}
.login-wrap {
@apply flex min-h-screen w-full max-w-[512px] flex-col gap-6 sm:min-h-0;
}
.login-brand {
@apply mx-auto flex items-center gap-3 px-4 pt-6 no-underline sm:px-0 sm:pt-0;
color: #172033;
}
.login-brand-mark {
@apply flex h-11 w-11 items-center justify-center rounded-2xl text-lg font-black;
background: linear-gradient(135deg, #ff8a3d 0%, #ef4444 100%);
color: #fffaf2;
}
.login-brand-text {
@apply text-lg font-black uppercase tracking-[0.18em];
}
.login-card {
@apply flex min-h-0 w-full flex-1 flex-col justify-center gap-10 bg-white/80 px-5 py-8 sm:flex-none sm:rounded-[1.5rem] sm:border sm:p-8;
border-color: rgba(23, 32, 51, 0.08);
box-shadow: none;
}
@media (min-width: 640px) {
.login-card {
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.08);
}
}
.visibility-toggle {
@apply cursor-pointer;
@apply transition-opacity duration-300;
@@ -194,7 +267,8 @@
"noAccount": "Don't have an account?",
"register": "Register",
"loginFailed": "Login failed. Please check your credentials.",
"continueWithGoogle": "Continue with Google"
"continueWithGoogle": "Continue with Google",
"continueWithFacebook": "Continue with Facebook"
},
"fr": {
"title": "Se connecter",
@@ -208,7 +282,8 @@
"noAccount": "Vous n'avez pas de compte?",
"register": "S'inscrire",
"loginFailed": "Échec de la connexion. Veuillez vérifier vos identifiants.",
"continueWithGoogle": "Continuer avec Google"
"continueWithGoogle": "Continuer avec Google",
"continueWithFacebook": "Continuer avec Facebook"
}
}
</i18n>

View File

@@ -0,0 +1,462 @@
<script setup>
import { computed } from 'vue';
import AppAvatar from '@/components/AppAvatar.vue';
const props = defineProps({
approvals: {
type: Array,
default: () => [],
},
isCreateMode: {
type: Boolean,
default: false,
},
approvalMode: {
type: String,
default: 'Required',
},
contentStatus: {
type: String,
default: 'Draft',
},
isSubmittingDecision: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['submit-decision']);
const isMultiLevelApproval = computed(() => props.approvalMode === 'Multi-level');
const isApprovalDisabled = computed(() => props.approvalMode === 'None');
const sortedApprovals = computed(() =>
[...props.approvals].sort((left, right) => {
const leftOrder = left.workflowStepSortOrder ?? Number.MAX_SAFE_INTEGER;
const rightOrder = right.workflowStepSortOrder ?? Number.MAX_SAFE_INTEGER;
if (left.workflowInstanceId || right.workflowInstanceId) {
return leftOrder - rightOrder;
}
return new Date(left.sentAt ?? 0).getTime() - new Date(right.sentAt ?? 0).getTime();
})
);
const currentPendingApprovalId = computed(() =>
sortedApprovals.value.find(approval => approval.state === 'Pending')?.id ?? null
);
const lifecycleRanks = {
Draft: 0,
'In production': 1,
'In approval': 2,
Approved: 3,
Scheduled: 4,
Published: 5,
};
const currentLifecycleRank = computed(() => lifecycleRanks[props.contentStatus] ?? 0);
const hasApprovalSteps = computed(() => sortedApprovals.value.length > 0);
const railSteps = computed(() => [
productionStep(),
...approvalSteps(),
publicationStep(),
]);
function submitDecision(approvalId) {
emit('submit-decision', approvalId, {
decision: 'Approved',
reviewerName: '',
reviewerEmail: '',
});
}
function formatApprovalStepMeta(approval) {
if (!approval.workflowInstanceId) {
return `${approval.stage} · ${approval.state}`;
}
const stepNumber = Number(approval.workflowStepSortOrder ?? 0) + 1;
const requiredCount = approval.workflowStepRequiredApproverCount ?? 1;
return `Step ${stepNumber} · ${approval.state} · ${requiredCount} required`;
}
function formatTarget(approval) {
if (!approval.workflowStepTargetType) {
return approval.reviewerEmail || 'Direct reviewer';
}
return `${approval.workflowStepTargetType}: ${approval.workflowStepTargetValue}`;
}
function formatDate(value) {
return value ? new Date(value).toLocaleDateString() : 'No due date';
}
function formatDateTime(value) {
return value ? new Date(value).toLocaleString() : '';
}
function stepStatus(approval) {
if (approval.state === 'Approved') {
return 'approved';
}
if (approval.id === currentPendingApprovalId.value) {
return 'current';
}
return 'pending';
}
function canRecordDecision(approval) {
if (approval.state !== 'Pending') {
return false;
}
return !approval.workflowInstanceId || approval.id === currentPendingApprovalId.value;
}
function productionStep() {
const status = currentLifecycleRank.value > lifecycleRanks['In production']
? 'approved'
: 'current';
return {
id: 'production',
kind: 'production',
title: 'Production',
state: props.contentStatus === 'Draft' ? 'Draft' : 'In production',
status,
meta: props.contentStatus === 'Draft'
? 'Content is being drafted.'
: 'Content is being prepared before approval.',
action: '',
};
}
function approvalSteps() {
if (hasApprovalSteps.value) {
return sortedApprovals.value.map((approval, index) => ({
id: approval.id,
kind: 'approval',
approval,
title: approval.stage || approval.reviewerName || `Approval ${index + 1}`,
state: approval.state,
status: stepStatus(approval),
meta: formatApprovalStepMeta(approval),
action: canRecordDecision(approval) ? 'Click the circle to approve.' : '',
}));
}
return [{
id: 'approval',
kind: 'approval-empty',
title: 'Approval',
state: approvalStateLabel(),
status: approvalSyntheticStatus(),
meta: approvalEmptyMeta(),
action: '',
}];
}
function publicationStep() {
const status = props.contentStatus === 'Published'
? 'published'
: props.contentStatus === 'Scheduled'
? 'scheduled'
: 'pending';
return {
id: 'publication',
kind: 'publication',
title: props.contentStatus === 'Published' ? 'Published' : 'Publication',
state: props.contentStatus === 'Published'
? 'Published'
: props.contentStatus === 'Scheduled'
? 'Scheduled'
: 'Pending',
status,
meta: props.contentStatus === 'Published'
? 'Content has been published.'
: props.contentStatus === 'Scheduled'
? 'Content is scheduled for publishing.'
: 'Content is not scheduled or published yet.',
action: '',
};
}
function approvalSyntheticStatus() {
if (isApprovalDisabled.value || currentLifecycleRank.value > lifecycleRanks['In approval']) {
return 'approved';
}
if (props.contentStatus === 'In approval') {
return 'current';
}
return 'pending';
}
function approvalStateLabel() {
if (isApprovalDisabled.value) {
return 'Skipped';
}
if (currentLifecycleRank.value > lifecycleRanks['In approval']) {
return 'Approved';
}
if (props.contentStatus === 'In approval') {
return 'In approval';
}
return 'Pending';
}
function approvalEmptyMeta() {
if (isApprovalDisabled.value) {
return 'Approval is disabled for this workspace.';
}
if (isMultiLevelApproval.value) {
return 'Move this content to In approval to start the configured workflow steps.';
}
return 'No approval activity yet.';
}
function stepLabel(step, index) {
const action = step.action ? ` ${step.action}` : '';
return `${step.title}. ${step.state}. ${step.meta}${action || ''}`.trim();
}
</script>
<template>
<aside
class="approval-panel"
aria-label="Approval workflow"
>
<div
v-if="isCreateMode"
class="approval-empty"
>
<span class="step-circle is-muted">1</span>
<div class="step-popover">
<strong>Approval</strong>
<span>Save the content first to see workflow steps.</span>
</div>
</div>
<ol
v-else
class="approval-stepper"
>
<li
v-for="(step, index) in railSteps"
:key="step.id"
class="approval-step"
:class="`is-${step.status}`"
>
<button
class="step-circle"
type="button"
:disabled="step.kind !== 'approval' || !canRecordDecision(step.approval) || isSubmittingDecision"
:aria-label="stepLabel(step, index)"
@click="submitDecision(step.approval.id)"
>
{{ index + 1 }}
</button>
<div class="step-popover">
<div class="popover-heading">
<strong>{{ step.title }}</strong>
<span>{{ step.state }}</span>
</div>
<div class="popover-meta">
<span>Workflow</span>
<strong>{{ step.meta }}</strong>
</div>
<div class="popover-meta">
<template v-if="step.kind === 'approval'">
<span>Approver</span>
<strong>{{ step.approval.reviewerName || formatTarget(step.approval) }}</strong>
<small>{{ formatTarget(step.approval) }}</small>
</template>
<template v-else>
<span>Status</span>
<strong>{{ step.state }}</strong>
</template>
</div>
<div
v-if="step.kind === 'approval'"
class="popover-meta"
>
<span>Due</span>
<strong>{{ formatDate(step.approval.dueAt) }}</strong>
</div>
<div
v-if="step.kind === 'approval' && step.approval.decisions?.length"
class="decision-list"
>
<article
v-for="decision in step.approval.decisions"
:key="decision.id"
class="decision-row"
>
<AppAvatar
:name="decision.decidedByName"
:email="decision.decidedByEmail"
:src="decision.decidedByPortraitUrl"
size="sm"
/>
<div>
<strong>{{ decision.decidedByName }}</strong>
<span>{{ decision.decision }} · {{ formatDateTime(decision.createdAt) }}</span>
<small v-if="decision.comment">{{ decision.comment }}</small>
</div>
</article>
</div>
<div
v-else
class="popover-meta"
>
<span>Action</span>
<strong>{{ step.action || 'No action available' }}</strong>
</div>
</div>
</li>
</ol>
</aside>
</template>
<style scoped>
.approval-panel {
@apply relative flex w-11 justify-center self-start;
}
.approval-empty strong,
.popover-heading strong,
.popover-meta strong,
.decision-row strong {
color: #172033;
}
.approval-empty span,
.popover-heading span,
.popover-meta span,
.popover-meta small,
.decision-row span,
.decision-row small {
@apply text-sm leading-6;
color: #526178;
}
.approval-stepper,
.decision-list {
@apply flex flex-col gap-14;
}
.approval-step {
@apply relative flex justify-center;
}
.approval-step:not(:last-child)::after {
@apply absolute bottom-[-3.5rem] top-10 border-l-2 border-dashed;
content: '';
left: 50%;
transform: translateX(-50%);
border-color: rgba(23, 32, 51, 0.18);
}
.approval-empty {
@apply relative flex justify-center;
}
.step-circle {
@apply relative z-10 flex h-9 w-9 items-center justify-center rounded-full border text-xs font-black transition;
background: #fffdf8;
border-color: rgba(23, 32, 51, 0.16);
color: #526178;
}
button.step-circle:not(:disabled) {
@apply cursor-pointer shadow-sm;
}
button.step-circle:not(:disabled):hover,
button.step-circle:not(:disabled):focus-visible {
transform: scale(1.06);
}
button.step-circle:disabled {
cursor: default;
}
.step-circle.is-muted {
background: rgba(23, 32, 51, 0.04);
}
.approval-step.is-approved .step-circle {
background: #0f766e;
border-color: #0f766e;
color: #fffaf2;
}
.approval-step.is-scheduled .step-circle {
background: #b45309;
border-color: #b45309;
color: #fffaf2;
}
.approval-step.is-published .step-circle {
background: #7c3aed;
border-color: #7c3aed;
color: #fffaf2;
}
.approval-step.is-current .step-circle {
background: #172033;
border-color: #172033;
color: #fffaf2;
}
.step-popover {
@apply pointer-events-none absolute left-[calc(100%+0.75rem)] top-0 z-20 flex w-[18rem] translate-y-2 flex-col gap-3 rounded-[1rem] border p-4 opacity-0 shadow-xl transition;
background: #ffffff;
border-color: rgba(23, 32, 51, 0.12);
}
.approval-step:hover .step-popover,
.approval-step:focus-within .step-popover,
.approval-empty:hover .step-popover {
@apply pointer-events-auto translate-y-0 opacity-100;
}
.popover-heading {
@apply flex items-center justify-between gap-3;
}
.popover-meta {
@apply flex flex-col gap-1;
}
.decision-row {
@apply flex items-start gap-3 rounded-[0.875rem] border p-3;
background: #f8fafc;
border-color: rgba(23, 32, 51, 0.08);
}
.decision-row div {
@apply flex min-w-0 flex-col gap-1;
}
@media (max-width: 639px) {
.step-popover {
width: min(18rem, calc(100vw - 5rem));
}
}
</style>

View File

@@ -20,7 +20,6 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
asset: false,
assetRevision: false,
comment: false,
approval: false,
decision: false,
status: false,
});
@@ -159,26 +158,6 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
}
}
async function createApproval(contentItemId, payload) {
actions.approval = true;
try {
const response = await client.post('/api/approvals', {
...payload,
contentItemId,
workspaceId: workspaceStore.activeWorkspaceId,
});
if (response.data) {
approvals.value = [response.data, ...approvals.value];
await fetchContentItem(contentItemId);
await fetchNotifications(contentItemId);
}
return response.data;
} finally {
actions.approval = false;
}
}
async function submitDecision(contentItemId, approvalId, payload) {
actions.decision = true;
@@ -248,7 +227,6 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
addAssetRevision,
addComment,
resolveComment,
createApproval,
submitDecision,
updateStatus,
};

View File

@@ -1,10 +1,11 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, watch } from 'vue';
import { computed, onBeforeUnmount, reactive, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useSessionStorage } from '@vueuse/core';
import AppAvatar from '@/components/AppAvatar.vue';
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
import ContentApprovalPanel from '@/features/content/components/ContentApprovalPanel.vue';
import { useContentItemDetailStore } from '@/features/content/stores/contentItemDetailStore.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
@@ -33,26 +34,10 @@
placements: [],
});
const approvalForm = reactive({
stage: 'Internal',
reviewerName: '',
reviewerEmail: '',
dueAt: '',
});
const commentForm = reactive({
body: '',
});
const decisionForms = reactive({});
const manualStatuses = [
'Draft',
'In production',
'In approval',
'Approved',
'Scheduled',
'Published',
];
const saveError = reactive({
message: '',
});
@@ -88,7 +73,7 @@
new Map(campaignsStore.campaigns.map(campaign => [campaign.id, campaign.name]))
);
const editorKey = computed(() => isCreateMode.value ? `new:${route.query.campaignId ?? 'default'}` : String(route.params.id));
const isMultiLevelApproval = computed(() => workspaceStore.activeWorkspace?.approvalMode === 'Multi-level');
const approvalMode = computed(() => workspaceStore.activeWorkspace?.approvalMode ?? 'Required');
function blankPlacement(channel = null) {
return {
@@ -112,29 +97,6 @@
};
}
function getDecisionForm(approvalId) {
if (!decisionForms[approvalId]) {
decisionForms[approvalId] = {
decision: 'Approved',
comment: '',
reviewerName: '',
reviewerEmail: '',
};
}
return decisionForms[approvalId];
}
function formatApprovalStepMeta(approval) {
if (!approval.workflowInstanceId) {
return `${approval.stage} · ${approval.state}`;
}
const stepNumber = Number(approval.workflowStepSortOrder ?? 0) + 1;
const requiredCount = approval.workflowStepRequiredApproverCount ?? 1;
return `Step ${stepNumber} · ${approval.state} · ${requiredCount} required`;
}
function syncPlacementChannel(placement, value) {
const channel = availableChannels.value.find(candidate => candidate.id === value);
placement.channelId = value;
@@ -353,30 +315,12 @@
}
}
async function submitApproval() {
async function submitDecision(approvalId, payload) {
if (!contentItemId.value) {
return;
}
await detailStore.createApproval(contentItemId.value, {
...approvalForm,
dueAt: approvalForm.dueAt ? new Date(approvalForm.dueAt).toISOString() : null,
});
approvalForm.stage = 'Internal';
approvalForm.reviewerName = '';
approvalForm.reviewerEmail = '';
approvalForm.dueAt = '';
}
async function submitDecision(approvalId) {
if (!contentItemId.value) {
return;
}
const formValue = getDecisionForm(approvalId);
await detailStore.submitDecision(contentItemId.value, approvalId, formValue);
formValue.comment = '';
await detailStore.submitDecision(contentItemId.value, approvalId, payload);
}
async function submitComment() {
@@ -388,22 +332,10 @@
commentForm.body = '';
}
async function moveStatus(status) {
if (!contentItemId.value) {
return;
}
await detailStore.updateStatus(contentItemId.value, status);
}
function formatDateTime(value) {
return value ? new Date(value).toLocaleString() : '';
}
function formatDate(value) {
return value ? new Date(value).toLocaleDateString() : 'No due date';
}
watch(
() => [
isCreateMode.value,
@@ -434,12 +366,6 @@
{ deep: true }
);
onMounted(async () => {
if (!isCreateMode.value) {
await hydrateEditor();
}
});
onBeforeUnmount(() => {
detailStore.reset();
});
@@ -502,165 +428,32 @@
{{ saveError.message }}
</div>
<div
v-if="!isCreateMode && item"
class="quick-actions"
>
<button
v-for="status in manualStatuses"
:key="status"
class="secondary-button"
:disabled="detailStore.actions.status || item.status === status"
@click="moveStatus(status)"
>
{{ status }}
</button>
</div>
<div class="editor-grid">
<aside class="panel side-panel">
<div class="panel-heading">
<strong>Approval</strong>
<span v-if="!isCreateMode">{{ detailStore.approvals.length }} {{ isMultiLevelApproval ? 'steps' : 'requests' }}</span>
</div>
<section class="work-panel">
<ContentApprovalPanel
:approvals="detailStore.approvals"
:approval-mode="approvalMode"
:content-status="item?.status ?? 'Draft'"
:is-create-mode="isCreateMode"
:is-submitting-decision="detailStore.actions.decision"
@submit-decision="submitDecision"
/>
<div
v-if="isCreateMode"
class="empty-note"
>
Save the content first to request approvals.
</div>
<main class="content-panel">
<div class="content-section">
<div class="section-title-row">
<strong>Content</strong>
<span>{{ placementSummary || 'No channels selected yet' }}</span>
</div>
<template v-else>
<div
v-if="isMultiLevelApproval"
class="empty-note"
>
Move this content to In approval to start the configured workflow steps.
</div>
<div
v-else
class="panel-stack"
>
<label class="field">
<span>Stage</span>
<select v-model="approvalForm.stage">
<option value="Internal">Internal</option>
<option value="Client">Client</option>
</select>
</label>
<label class="field">
<span>Reviewer name</span>
<input
v-model="approvalForm.reviewerName"
type="text"
/>
</label>
<label class="field">
<span>Reviewer email</span>
<input
v-model="approvalForm.reviewerEmail"
type="email"
/>
</label>
<label class="field">
<span>Due date</span>
<input
v-model="approvalForm.dueAt"
type="date"
/>
</label>
<button
class="primary-button"
:disabled="detailStore.actions.approval"
@click="submitApproval"
>
{{ detailStore.actions.approval ? 'Sending...' : 'Request approval' }}
</button>
</div>
<div class="card-stack">
<article
v-for="approval in detailStore.approvals"
:key="approval.id"
class="sub-card"
>
<div class="sub-card-header">
<div>
<strong>{{ approval.reviewerName }}</strong>
<span>{{ formatApprovalStepMeta(approval) }}</span>
</div>
<small>{{ formatDate(approval.dueAt) }}</small>
</div>
<div class="timeline-list compact">
<article
v-for="decision in approval.decisions"
:key="decision.id"
class="timeline-row"
>
<div class="identity-row">
<AppAvatar
:name="decision.decidedByName"
:email="decision.decidedByEmail"
:src="decision.decidedByPortraitUrl"
size="sm"
/>
<div>
<strong>{{ decision.decision }}</strong>
<span>{{ decision.comment || decision.decidedByName }}</span>
</div>
</div>
<small>{{ formatDateTime(decision.createdAt) }}</small>
</article>
</div>
<div
v-if="approval.state === 'Pending'"
class="panel-stack subtle"
>
<label class="field">
<span>Decision</span>
<select v-model="getDecisionForm(approval.id).decision">
<option value="Approved">Approved</option>
</select>
</label>
<label class="field">
<span>Comment</span>
<input
v-model="getDecisionForm(approval.id).comment"
type="text"
/>
</label>
<button
class="secondary-button"
:disabled="detailStore.actions.decision"
@click="submitDecision(approval.id)"
>
Record decision
</button>
</div>
</article>
</div>
</template>
</aside>
<main class="panel content-panel">
<div class="content-section">
<div class="section-title-row">
<strong>Content</strong>
<span>{{ placementSummary || 'No channels selected yet' }}</span>
</div>
<div class="form-grid">
<label class="field">
<span>Title</span>
<input
v-model="form.title"
type="text"
/>
</label>
<div class="form-grid">
<label class="field">
<span>Title</span>
<input
v-model="form.title"
type="text"
/>
</label>
<label class="field">
<span>Campaign</span>
@@ -921,8 +714,9 @@
>
Add at least one channel to define where this content will be published.
</div>
</div>
</main>
</div>
</main>
</section>
<aside class="panel side-panel">
<div class="panel-heading">
@@ -1024,7 +818,6 @@
.editor-header p,
.panel-heading span,
.sub-card span,
.timeline-row span,
.timeline-row small,
.empty-note,
@@ -1035,7 +828,6 @@
}
.header-actions,
.quick-actions,
.status-badges {
@apply flex flex-wrap items-center gap-3;
}
@@ -1062,7 +854,13 @@
}
.editor-grid {
@apply grid gap-4 xl:grid-cols-[18rem_minmax(0,1fr)_20rem];
@apply grid gap-4 xl:grid-cols-[minmax(0,1fr)_22rem];
}
.work-panel {
@apply grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] items-start gap-4 rounded-[1.75rem] border p-5;
background: rgba(255, 255, 255, 0.9);
border-color: rgba(23, 32, 51, 0.08);
}
.panel {
@@ -1076,20 +874,18 @@
}
.content-panel {
@apply gap-6;
@apply flex min-h-0 flex-col gap-6;
}
.panel-heading,
.section-title-row,
.placement-header,
.sub-card-header {
.placement-header {
@apply flex items-start justify-between gap-3;
}
.panel-heading strong,
.section-title-row strong,
.placement-header strong,
.sub-card strong,
.timeline-row strong {
color: #172033;
}
@@ -1172,7 +968,6 @@
}
.placement-card,
.sub-card,
.media-card {
@apply rounded-[1.25rem] border p-4;
background: #fffaf2;

View File

@@ -0,0 +1,113 @@
<template>
<header class="site-menu">
<div class="site-menu-inner">
<router-link
class="site-brand"
to="/"
>
<span class="site-brand-mark">S</span>
<span class="site-brand-text">Socialize</span>
</router-link>
<nav
class="site-nav"
aria-label="Public site navigation"
>
<router-link to="/product">Product</router-link>
<router-link to="/pricing">Pricing</router-link>
<div class="site-nav-group">
<button type="button">Resources</button>
<div class="site-nav-menu">
<router-link to="/blogs">Blogs</router-link>
<router-link to="/guides">Guides</router-link>
</div>
</div>
</nav>
<router-link
class="site-login"
to="/login"
>
Login
</router-link>
</div>
</header>
</template>
<style scoped>
.site-menu {
@apply sticky top-0 z-30 w-full;
background: rgba(255, 250, 242, 0.9);
backdrop-filter: blur(18px);
border-bottom: 1px solid rgba(23, 32, 51, 0.08);
}
.site-menu-inner {
@apply mx-auto flex w-full max-w-7xl items-center justify-between gap-4 px-5 py-4 md:px-8;
}
.site-brand {
@apply flex min-w-0 items-center gap-3 no-underline;
color: #172033;
}
.site-brand-mark {
@apply flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-2xl text-base font-black;
background: linear-gradient(135deg, #ff8a3d 0%, #ef4444 100%);
color: #fffaf2;
}
.site-brand-text {
@apply truncate text-lg font-black uppercase tracking-[0.18em];
}
.site-nav {
@apply hidden items-center justify-center gap-2 sm:flex;
}
.site-nav a {
@apply rounded-full px-4 py-2 text-sm font-semibold no-underline transition-colors;
color: #44516a;
}
.site-nav a:hover,
.site-nav-group:hover > button {
background: rgba(23, 32, 51, 0.06);
color: #172033;
}
.site-nav-group {
@apply relative;
}
.site-nav-group > button {
@apply rounded-full px-4 py-2 text-sm font-semibold transition-colors;
color: #44516a;
}
.site-nav-menu {
@apply invisible absolute left-0 top-[calc(100%+0.5rem)] flex min-w-36 flex-col gap-1 rounded-[1rem] border p-2 opacity-0 transition-opacity;
background: rgba(255, 255, 255, 0.98);
border-color: rgba(23, 32, 51, 0.08);
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.12);
}
.site-nav-group:hover .site-nav-menu,
.site-nav-group:focus-within .site-nav-menu {
@apply visible opacity-100;
}
.site-nav-menu a {
@apply rounded-[0.75rem] px-3 py-2;
}
.site-login {
@apply flex h-10 items-center rounded-full px-4 text-sm font-bold no-underline transition-colors;
background: #172033;
color: #fffaf2;
}
.site-login:hover {
background: #0f766e;
}
</style>

View File

@@ -0,0 +1,51 @@
import { useHead } from '@vueuse/head';
function getCanonicalUrl(path) {
const configuredSiteUrl = import.meta.env.VITE_PUBLIC_SITE_URL
?? (typeof process !== 'undefined' ? process.env.VITE_PUBLIC_SITE_URL : undefined)
?? (typeof process !== 'undefined' ? process.env.SITE_URL : undefined);
if (configuredSiteUrl) {
return new URL(path, `${configuredSiteUrl.replace(/\/$/, '')}/`).toString();
}
if (typeof window === 'undefined') {
return path;
}
return new URL(path, window.location.origin).toString();
}
export function usePublicPageMeta({ title, description, path }) {
useHead({
title,
meta: [
{
name: 'description',
content: description,
},
{
name: 'robots',
content: 'index,follow',
},
{
property: 'og:title',
content: title,
},
{
property: 'og:description',
content: description,
},
{
property: 'og:type',
content: 'website',
},
],
link: [
{
rel: 'canonical',
href: getCanonicalUrl(path),
},
],
});
}

View File

@@ -0,0 +1,59 @@
<script setup>
import LandingSiteMenu from '@/features/landing/components/LandingSiteMenu.vue';
import { usePublicPageMeta } from '@/features/landing/publicPageMeta.js';
usePublicPageMeta({
title: 'Blogs | Socialize',
description: 'Practical articles on content review workflows, client approval, revision tracking, and publication handoff.',
path: '/blogs',
});
</script>
<template>
<div class="public-page">
<LandingSiteMenu />
<main class="public-page-content">
<section class="public-page-panel">
<div class="eyebrow">Blogs</div>
<h1>Practical notes on content review workflows.</h1>
<p>
Articles are coming soon. This area will cover approval operations, client review habits,
revision tracking, and publication handoff.
</p>
</section>
</main>
</div>
</template>
<style scoped>
.public-page {
@apply min-h-screen w-full;
}
.public-page-content {
@apply mx-auto flex w-full max-w-7xl flex-col px-5 py-8 md:px-8 md:py-12;
}
.public-page-panel {
@apply rounded-[2rem] p-6 md:p-10;
background: rgba(255, 255, 255, 0.84);
border: 1px solid rgba(23, 32, 51, 0.08);
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.06);
}
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.26em];
color: #ff8a3d;
}
h1 {
@apply mt-4 max-w-4xl text-4xl font-black leading-tight md:text-6xl;
color: #172033;
}
p {
@apply mt-5 max-w-3xl text-base leading-7 md:text-lg;
color: #44516a;
}
</style>

View File

@@ -0,0 +1,59 @@
<script setup>
import LandingSiteMenu from '@/features/landing/components/LandingSiteMenu.vue';
import { usePublicPageMeta } from '@/features/landing/publicPageMeta.js';
usePublicPageMeta({
title: 'Guides | Socialize',
description: 'Reusable guides for content intake, review rounds, approval decisions, and delivery readiness.',
path: '/guides',
});
</script>
<template>
<div class="public-page">
<LandingSiteMenu />
<main class="public-page-content">
<section class="public-page-panel">
<div class="eyebrow">Guides</div>
<h1>Reusable guides for managing review and approval work.</h1>
<p>
Guides are coming soon. This area will collect repeatable playbooks for content intake,
review rounds, approval decisions, and delivery readiness.
</p>
</section>
</main>
</div>
</template>
<style scoped>
.public-page {
@apply min-h-screen w-full;
}
.public-page-content {
@apply mx-auto flex w-full max-w-7xl flex-col px-5 py-8 md:px-8 md:py-12;
}
.public-page-panel {
@apply rounded-[2rem] p-6 md:p-10;
background: rgba(255, 255, 255, 0.84);
border: 1px solid rgba(23, 32, 51, 0.08);
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.06);
}
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.26em];
color: #ff8a3d;
}
h1 {
@apply mt-4 max-w-4xl text-4xl font-black leading-tight md:text-6xl;
color: #172033;
}
p {
@apply mt-5 max-w-3xl text-base leading-7 md:text-lg;
color: #44516a;
}
</style>

View File

@@ -1,5 +1,13 @@
<script setup>
import { computed } from 'vue';
import LandingSiteMenu from '@/features/landing/components/LandingSiteMenu.vue';
import { usePublicPageMeta } from '@/features/landing/publicPageMeta.js';
usePublicPageMeta({
title: 'Socialize | Social media approval workflow',
description: 'Socialize helps teams manage social media content review, revisions, approval decisions, and publication handoff in one workflow.',
path: '/',
});
const pillars = computed(() => [
{
@@ -25,37 +33,43 @@
</script>
<template>
<div class="landing-shell">
<section class="hero-card">
<div class="hero-copy">
<div class="eyebrow">Social media approval workflow</div>
<h1>Replace Drive links, scattered comments, and manual follow-up with one review system.</h1>
<p>
Socialize is being rebuilt as an agency workflow product for content review, revision tracking,
client approval, and publication readiness.
</p>
<div class="hero-actions">
<router-link to="/login">
<button class="primary">Open the app</button>
</router-link>
<router-link to="/register">
<button class="secondary">Create an internal account</button>
</router-link>
</div>
</div>
<div class="landing-page">
<LandingSiteMenu />
<div class="hero-panel">
<div class="hero-panel-title">Version 1 workflow</div>
<ol class="workflow-list">
<li
v-for="step in workflow"
:key="step"
>
{{ step }}
</li>
</ol>
</div>
</section>
<main class="landing-shell">
<section
id="products"
class="hero-card"
>
<div class="hero-copy">
<div class="eyebrow">Social media approval workflow</div>
<h1>Replace Drive links, scattered comments, and manual follow-up with one review system.</h1>
<p>
Socialize is being rebuilt as an agency workflow product for content review, revision tracking,
client approval, and publication readiness.
</p>
<div class="hero-actions">
<router-link to="/login">
<button class="primary">Open the app</button>
</router-link>
<router-link to="/register">
<button class="secondary">Create an internal account</button>
</router-link>
</div>
</div>
<div class="hero-panel">
<div class="hero-panel-title">Version 1 workflow</div>
<ol class="workflow-list">
<li
v-for="step in workflow"
:key="step"
>
{{ step }}
</li>
</ol>
</div>
</section>
<section class="pillars-grid">
<article
@@ -88,10 +102,28 @@
</div>
</div>
</section>
<section
id="pricing"
class="pricing-card"
>
<div>
<div class="eyebrow">Pricing</div>
<h2>Workspace pricing for teams that manage content approvals with clients.</h2>
</div>
<router-link to="/login">
<button class="secondary pricing-action">Open the app</button>
</router-link>
</section>
</main>
</div>
</template>
<style scoped>
.landing-page {
@apply min-h-screen w-full;
}
.landing-shell {
@apply mx-auto flex w-full max-w-7xl flex-col gap-8 px-5 py-8 md:px-8 md:py-12;
}
@@ -189,4 +221,20 @@
@apply mt-2 block text-sm leading-6;
color: #3f4d63;
}
.pricing-card {
@apply flex flex-col gap-5 rounded-[1.75rem] p-6 md:flex-row md:items-center md:justify-between md:p-8;
background: rgba(255, 255, 255, 0.84);
border: 1px solid rgba(23, 32, 51, 0.08);
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.06);
}
.pricing-card h2 {
@apply mt-3 max-w-3xl text-2xl font-black leading-tight md:text-3xl;
color: #172033;
}
.pricing-action {
@apply w-full sm:w-auto;
}
</style>

View File

@@ -0,0 +1,75 @@
<script setup>
import LandingSiteMenu from '@/features/landing/components/LandingSiteMenu.vue';
import { usePublicPageMeta } from '@/features/landing/publicPageMeta.js';
usePublicPageMeta({
title: 'Pricing | Socialize',
description: 'Socialize workspace pricing for teams managing social media content approvals with clients.',
path: '/pricing',
});
</script>
<template>
<div class="public-page">
<LandingSiteMenu />
<main class="public-page-content">
<section class="public-page-panel">
<div class="eyebrow">Pricing</div>
<h1>Simple workspace pricing for teams managing client approvals.</h1>
<p>
Pricing details are coming soon. For now, Socialize is focused on the core review workflow:
workspaces, content items, assets, comments, and approval decisions.
</p>
<router-link
class="pricing-login"
to="/login"
>
Login
</router-link>
</section>
</main>
</div>
</template>
<style scoped>
.public-page {
@apply min-h-screen w-full;
}
.public-page-content {
@apply mx-auto flex w-full max-w-7xl flex-col px-5 py-8 md:px-8 md:py-12;
}
.public-page-panel {
@apply rounded-[2rem] p-6 md:p-10;
background: rgba(255, 255, 255, 0.84);
border: 1px solid rgba(23, 32, 51, 0.08);
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.06);
}
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.26em];
color: #ff8a3d;
}
h1 {
@apply mt-4 max-w-4xl text-4xl font-black leading-tight md:text-6xl;
color: #172033;
}
p {
@apply mt-5 max-w-3xl text-base leading-7 md:text-lg;
color: #44516a;
}
.pricing-login {
@apply mt-8 inline-flex h-11 w-fit items-center rounded-full px-5 text-sm font-bold no-underline transition-colors;
background: #172033;
color: #fffaf2;
}
.pricing-login:hover {
background: #0f766e;
}
</style>

View File

@@ -0,0 +1,59 @@
<script setup>
import LandingSiteMenu from '@/features/landing/components/LandingSiteMenu.vue';
import { usePublicPageMeta } from '@/features/landing/publicPageMeta.js';
usePublicPageMeta({
title: 'Product | Socialize',
description: 'Socialize keeps content items, assets, revisions, comments, approval decisions, and publishing handoff details in one workspace.',
path: '/product',
});
</script>
<template>
<div class="public-page">
<LandingSiteMenu />
<main class="public-page-content">
<section class="public-page-panel">
<div class="eyebrow">Product</div>
<h1>Social media content approval, organized around the work itself.</h1>
<p>
Socialize keeps content items, assets, revisions, comments, approval decisions, and publishing
handoff details in one workspace so teams do not have to coordinate review across scattered tools.
</p>
</section>
</main>
</div>
</template>
<style scoped>
.public-page {
@apply min-h-screen w-full;
}
.public-page-content {
@apply mx-auto flex w-full max-w-7xl flex-col px-5 py-8 md:px-8 md:py-12;
}
.public-page-panel {
@apply rounded-[2rem] p-6 md:p-10;
background: rgba(255, 255, 255, 0.84);
border: 1px solid rgba(23, 32, 51, 0.08);
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.06);
}
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.26em];
color: #ff8a3d;
}
h1 {
@apply mt-4 max-w-4xl text-4xl font-black leading-tight md:text-6xl;
color: #172033;
}
p {
@apply mt-5 max-w-3xl text-base leading-7 md:text-lg;
color: #44516a;
}
</style>

View File

@@ -0,0 +1,142 @@
import { computed, ref, watch } from 'vue';
import { defineStore } from 'pinia';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useClient } from '@/plugins/api.js';
export const organizationPermissions = {
manageOrganizationSettings: 'ManageOrganizationSettings',
manageOrganizationMembers: 'ManageOrganizationMembers',
createWorkspaces: 'CreateWorkspaces',
manageWorkspaces: 'ManageWorkspaces',
manageBilling: 'ManageBilling',
manageConnectors: 'ManageConnectors',
accessOwnedWorkspaces: 'AccessOwnedWorkspaces',
};
export const useOrganizationStore = defineStore('organization', () => {
const authStore = useAuthStore();
const client = useClient();
const organizations = ref([]);
const selectedOrganizationId = ref(null);
const detailsById = ref({});
const isLoading = ref(false);
const isLoadingDetails = ref(false);
const error = ref(null);
const activeOrganization = computed(() =>
organizations.value.find(organization => organization.id === selectedOrganizationId.value) ?? null
);
function userCan(organization, permission) {
return Boolean(organization?.currentUserPermissions?.includes(permission));
}
function setSelectedOrganization(organizationId) {
if (organizations.value.some(organization => organization.id === organizationId)) {
selectedOrganizationId.value = organizationId;
}
}
function setSelectedOrganizationFromWorkspace(workspace) {
if (workspace?.organizationId) {
if (
organizations.value.length === 0 ||
organizations.value.some(organization => organization.id === workspace.organizationId)
) {
selectedOrganizationId.value = workspace.organizationId;
}
}
}
async function fetchOrganizations() {
if (!authStore.isAuthenticated) {
organizations.value = [];
selectedOrganizationId.value = null;
detailsById.value = {};
error.value = null;
return [];
}
isLoading.value = true;
error.value = null;
try {
const response = await client.get('/api/organizations');
organizations.value = response.data ?? [];
if (!organizations.value.some(organization => organization.id === selectedOrganizationId.value)) {
selectedOrganizationId.value = organizations.value[0]?.id ?? null;
}
return organizations.value;
} catch (fetchError) {
console.error('Failed to fetch organizations:', fetchError);
organizations.value = [];
selectedOrganizationId.value = null;
error.value = 'Failed to load organizations.';
return [];
} finally {
isLoading.value = false;
}
}
async function fetchOrganization(organizationId) {
if (!authStore.isAuthenticated || !organizationId) {
return null;
}
isLoadingDetails.value = true;
error.value = null;
try {
const response = await client.get(`/api/organizations/${organizationId}`);
if (response.data) {
detailsById.value = {
...detailsById.value,
[organizationId]: response.data,
};
selectedOrganizationId.value = organizationId;
}
return response.data ?? null;
} catch (fetchError) {
console.error('Failed to fetch organization:', fetchError);
error.value = 'Failed to load organization.';
return null;
} finally {
isLoadingDetails.value = false;
}
}
watch(
() => authStore.isAuthenticated,
async isAuthenticated => {
if (!isAuthenticated) {
organizations.value = [];
selectedOrganizationId.value = null;
detailsById.value = {};
error.value = null;
return;
}
await fetchOrganizations();
},
{ immediate: true }
);
return {
organizations,
selectedOrganizationId,
activeOrganization,
detailsById,
isLoading,
isLoadingDetails,
error,
userCan,
setSelectedOrganization,
setSelectedOrganizationFromWorkspace,
fetchOrganizations,
fetchOrganization,
};
});

View File

@@ -0,0 +1,382 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import {
mdiAccountGroupOutline,
mdiBriefcaseOutline,
mdiCogOutline,
mdiCreditCardOutline,
mdiLanConnect,
} from '@mdi/js';
import {
organizationPermissions,
useOrganizationStore,
} from '@/features/organizations/stores/organizationStore.js';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const organizationStore = useOrganizationStore();
const workspaceStore = useWorkspaceStore();
const activeSectionKey = ref('profile');
const organizationId = computed(() => route.params.organizationId);
const organization = computed(() =>
organizationStore.detailsById[organizationId.value] ??
organizationStore.organizations.find(candidate => candidate.id === organizationId.value) ??
null
);
const permissions = computed(() => organization.value?.currentUserPermissions ?? []);
const canViewMembers = computed(() =>
permissions.value.includes(organizationPermissions.manageOrganizationMembers)
);
const canViewBilling = computed(() =>
permissions.value.includes(organizationPermissions.manageBilling)
);
const canViewConnections = computed(() =>
permissions.value.includes(organizationPermissions.manageConnectors)
);
const canViewWorkspaces = computed(() =>
permissions.value.includes(organizationPermissions.manageWorkspaces) ||
permissions.value.includes(organizationPermissions.createWorkspaces)
);
const visibleSections = computed(() => [
{ key: 'profile', icon: mdiCogOutline, visible: true },
{ key: 'members', icon: mdiAccountGroupOutline, visible: canViewMembers.value },
{ key: 'billing', icon: mdiCreditCardOutline, visible: canViewBilling.value },
{ key: 'connections', icon: mdiLanConnect, visible: canViewConnections.value },
{ key: 'workspaces', icon: mdiBriefcaseOutline, visible: canViewWorkspaces.value },
].filter(section => section.visible));
const activeSection = computed(() =>
visibleSections.value.find(section => section.key === activeSectionKey.value) ??
visibleSections.value[0] ??
null
);
function hasPermission(permission) {
return permissions.value.includes(permission);
}
async function loadOrganization() {
if (!organizationId.value) {
return;
}
await organizationStore.fetchOrganization(organizationId.value);
}
async function openWorkspace(workspaceId) {
const workspace = organization.value?.workspaces?.find(candidate => candidate.id === workspaceId);
if (workspace) {
workspaceStore.setActiveWorkspace(workspace.id);
await router.push({ name: 'workspace-dashboard' });
}
}
onMounted(loadOrganization);
watch(organizationId, loadOrganization);
watch(
visibleSections,
sections => {
if (!sections.some(section => section.key === activeSectionKey.value)) {
activeSectionKey.value = sections[0]?.key ?? 'profile';
}
},
{ immediate: true }
);
</script>
<template>
<section class="organization-settings-shell">
<div class="settings-hero">
<div>
<div class="eyebrow">{{ t('organizationSettings.eyebrow') }}</div>
<h1>{{ organization?.name ?? t('organizationSettings.title') }}</h1>
<p>{{ t('organizationSettings.description') }}</p>
</div>
</div>
<div
v-if="organizationStore.isLoadingDetails && !organization"
class="page-message"
>
{{ t('organizationSettings.loading') }}
</div>
<div
v-else-if="organizationStore.error"
class="page-message error"
>
{{ organizationStore.error }}
</div>
<div
v-else-if="organization"
class="settings-page"
>
<nav
class="settings-tabs"
aria-label="Organization settings sections"
>
<button
v-for="section in visibleSections"
:key="section.key"
class="settings-tab"
:class="{ 'settings-tab-active': section.key === activeSectionKey }"
type="button"
@click="activeSectionKey = section.key"
>
<v-icon :icon="section.icon" />
<span>{{ t(`organizationSettings.sections.${section.key}.title`) }}</span>
</button>
</nav>
<div
v-if="activeSection"
class="settings-content"
>
<div class="section-heading">
<h2>{{ t(`organizationSettings.sections.${activeSection.key}.title`) }}</h2>
<p>{{ t(`organizationSettings.sections.${activeSection.key}.description`) }}</p>
</div>
<article class="content-card">
<div
v-if="activeSection.key === 'profile'"
class="detail-list"
>
<div>
<span>{{ t('organizationSettings.fields.name') }}</span>
<strong>{{ organization.name }}</strong>
</div>
<div>
<span>{{ t('organizationSettings.fields.createdAt') }}</span>
<strong>{{ new Date(organization.createdAt).toLocaleDateString() }}</strong>
</div>
<div class="permissions-panel">
<span>{{ t('organizationSettings.permissions.title') }}</span>
<div class="permission-grid">
<span
v-for="permission in Object.values(organizationPermissions)"
:key="permission"
:class="{ enabled: hasPermission(permission) }"
>
{{ t(`organizationSettings.permissions.items.${permission}`) }}
</span>
</div>
</div>
</div>
<div
v-else-if="activeSection.key === 'members'"
class="table-list"
>
<div
v-for="member in organization.members"
:key="member.userId"
class="table-row"
>
<div>
<strong>{{ member.displayName }}</strong>
<span>{{ member.email }}</span>
</div>
<small>{{ t(`organizationSettings.roles.${member.role}`, member.role) }}</small>
</div>
<div
v-if="!organization.members?.length"
class="empty-state"
>
{{ t('organizationSettings.sections.members.empty') }}
</div>
</div>
<div
v-else-if="activeSection.key === 'billing'"
class="placeholder-panel"
>
<strong>{{ t('organizationSettings.sections.billing.placeholderTitle') }}</strong>
<span>{{ t('organizationSettings.sections.billing.placeholderText') }}</span>
</div>
<div
v-else-if="activeSection.key === 'connections'"
class="placeholder-panel"
>
<strong>{{ t('organizationSettings.sections.connections.placeholderTitle') }}</strong>
<span>{{ t('organizationSettings.sections.connections.placeholderText') }}</span>
</div>
<div
v-else-if="activeSection.key === 'workspaces'"
class="table-list"
>
<button
v-for="workspace in organization.workspaces"
:key="workspace.id"
class="table-row table-row-button"
type="button"
@click="openWorkspace(workspace.id)"
>
<div>
<strong>{{ workspace.name }}</strong>
<span>{{ workspace.timeZone }}</span>
</div>
<small>{{ workspace.slug }}</small>
</button>
<div
v-if="!organization.workspaces?.length"
class="empty-state"
>
{{ t('organizationSettings.sections.workspaces.empty') }}
</div>
</div>
</article>
</div>
</div>
</section>
</template>
<style scoped>
.organization-settings-shell {
@apply mx-auto flex w-full max-w-6xl flex-col gap-6 px-5 py-8 md:px-8;
}
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.2em];
color: #c2410c;
}
.settings-hero h1 {
@apply mt-3 text-3xl font-black md:text-4xl;
color: #172033;
}
.settings-hero p,
.section-heading p,
.detail-list span,
.table-row span,
.placeholder-panel span,
.empty-state {
@apply text-sm leading-6;
color: #526178;
}
.settings-page {
@apply flex flex-col gap-5;
}
.settings-tabs {
@apply flex flex-wrap gap-2 border-b pb-3;
border-color: rgba(23, 32, 51, 0.1);
}
.settings-tab {
@apply inline-flex h-10 items-center gap-2 rounded-[0.75rem] px-3 text-sm font-semibold transition-colors;
color: #526178;
}
.settings-tab:hover {
background: rgba(23, 32, 51, 0.06);
color: #172033;
}
.settings-tab-active {
background: #172033;
color: #fffaf2;
}
.settings-tab :deep(.v-icon) {
@apply text-lg;
}
.settings-content {
@apply flex flex-col gap-4;
}
.section-heading {
@apply flex flex-col gap-1;
}
.section-heading h2 {
@apply text-2xl font-black;
color: #172033;
}
.content-card {
@apply rounded-[0.75rem] border p-5;
background: rgba(255, 255, 255, 0.94);
border-color: rgba(23, 32, 51, 0.08);
}
.detail-list,
.table-list {
@apply flex flex-col gap-2;
}
.detail-list div,
.table-row {
@apply flex items-center justify-between gap-4 rounded-[0.75rem] px-4 py-3;
background: rgba(23, 32, 51, 0.04);
}
.table-row {
@apply text-left;
}
.table-row-button {
@apply w-full transition-colors;
}
.table-row-button:hover {
background: rgba(23, 32, 51, 0.08);
}
.table-row div {
@apply flex min-w-0 flex-col;
}
.detail-list strong,
.table-row strong,
.placeholder-panel strong {
@apply font-semibold;
color: #172033;
}
.table-row small {
@apply flex-shrink-0 text-xs font-bold uppercase;
color: #c2410c;
}
.permissions-panel,
.placeholder-panel,
.empty-state {
@apply rounded-[0.75rem] px-4 py-4;
background: rgba(23, 32, 51, 0.04);
}
.permissions-panel {
@apply flex-col items-start gap-3;
}
.placeholder-panel {
@apply flex flex-col gap-1;
}
.permission-grid {
@apply flex flex-wrap gap-2;
}
.permission-grid span {
@apply rounded-full px-3 py-2 text-xs font-bold;
background: rgba(23, 32, 51, 0.06);
color: #526178;
}
.permission-grid span.enabled {
background: rgba(15, 118, 110, 0.12);
color: #0f766e;
}
</style>

View File

@@ -1,10 +1,12 @@
import { computed, ref, watch } from 'vue';
import { defineStore } from 'pinia';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useOrganizationStore } from '@/features/organizations/stores/organizationStore.js';
import { useClient } from '@/plugins/api.js';
export const useWorkspaceStore = defineStore('workspace', () => {
const authStore = useAuthStore();
const organizationStore = useOrganizationStore();
const client = useClient();
const workspaces = ref([]);
@@ -42,6 +44,8 @@ export const useWorkspaceStore = defineStore('workspace', () => {
if (!workspaces.value.some(workspace => workspace.id === activeWorkspaceId.value)) {
activeWorkspaceId.value = workspaces.value[0]?.id ?? null;
}
organizationStore.setSelectedOrganizationFromWorkspace(activeWorkspace.value);
} catch (fetchError) {
console.error('Failed to fetch workspaces:', fetchError);
workspaces.value = [];
@@ -161,8 +165,14 @@ export const useWorkspaceStore = defineStore('workspace', () => {
}
function setActiveWorkspace(workspaceId) {
if (!workspaceId) {
activeWorkspaceId.value = null;
return;
}
if (workspaces.value.some(workspace => workspace.id === workspaceId)) {
activeWorkspaceId.value = workspaceId;
organizationStore.setSelectedOrganizationFromWorkspace(activeWorkspace.value);
}
}

View File

@@ -2,11 +2,13 @@
import { computed, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useOrganizationStore } from '@/features/organizations/stores/organizationStore.js';
import TimeZoneSelect from '@/features/workspaces/components/TimeZoneSelect.vue';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
const router = useRouter();
const { t } = useI18n();
const organizationStore = useOrganizationStore();
const workspaceStore = useWorkspaceStore();
const form = reactive({
@@ -23,6 +25,10 @@
return slugify(form.name);
});
const selectedOrganizationId = computed({
get: () => organizationStore.selectedOrganizationId,
set: value => organizationStore.setSelectedOrganization(value),
});
function computedDefaultTimeZone() {
return workspaceStore.activeWorkspace?.timeZone || 'America/Montreal';
@@ -48,13 +54,14 @@
const slug = slugify(form.slug || form.name);
const timeZone = form.timeZone.trim();
if (!name || !slug || !timeZone) {
if (!name || !slug || !timeZone || !selectedOrganizationId.value) {
formError.value = t('workspaceCreate.errors.required');
return;
}
try {
await workspaceStore.createWorkspace({
organizationId: selectedOrganizationId.value,
name,
slug,
timeZone,
@@ -114,6 +121,22 @@
/>
</label>
<label class="field">
<span>{{ t('workspaceCreate.fields.organization') }}</span>
<select
v-model="selectedOrganizationId"
:disabled="workspaceStore.isCreating || organizationStore.organizations.length <= 1"
>
<option
v-for="organization in organizationStore.organizations"
:key="organization.id"
:value="organization.id"
>
{{ organization.name }}
</option>
</select>
</label>
<label class="field">
<span>{{ t('workspaceCreate.fields.slug') }}</span>
<input
@@ -242,7 +265,8 @@
color: #172033;
}
.field input {
.field input,
.field select {
@apply rounded-[1rem] border px-4 py-3 text-sm;
background: #fffdf8;
border-color: rgba(23, 32, 51, 0.1);

View File

@@ -10,17 +10,6 @@
mdiPlus,
} from '@mdi/js';
const props = defineProps({
showBrand: {
type: Boolean,
default: true,
},
collapseBrand: {
type: Boolean,
default: false,
},
});
const route = useRoute();
const { t } = useI18n();
const authStore = useAuthStore();
@@ -73,21 +62,6 @@
<template>
<nav class="side-container">
<div class="brand-block">
<router-link
v-if="showBrand"
class="brand-link"
:class="{ 'brand-link-collapsed': collapseBrand }"
to="/"
>
<span class="brand-mark">S</span>
<div v-if="!collapseBrand">
<div class="brand-name">Socialize</div>
<div class="brand-caption">{{ t('nav.brandCaption') }}</div>
</div>
</router-link>
</div>
<div class="side-menu">
<div class="side-menu-items side-menu-left">
<WorkspaceSelector
@@ -123,44 +97,15 @@
<style scoped>
.side-container {
@apply sticky top-0 z-10 flex flex-col gap-4 px-5 py-4 md:flex-row md:items-center md:justify-between;
@apply sticky top-0 z-20 flex flex-col gap-4 px-5 py-4 md:flex-row md:items-center md:justify-between;
background: rgba(255, 250, 242, 0.82);
backdrop-filter: blur(18px);
border-bottom: 1px solid rgba(23, 32, 51, 0.08);
isolation: isolate;
}
.brand-block {
@apply flex items-center gap-3;
}
.brand-link {
@apply flex items-center gap-3 no-underline;
color: inherit;
}
.brand-link-collapsed {
@apply gap-0;
}
.brand-mark {
@apply flex h-11 w-11 items-center justify-center rounded-2xl text-lg font-black;
background: linear-gradient(135deg, #ff8a3d 0%, #ef4444 100%);
color: #fffaf2;
}
.brand-name {
@apply text-lg font-black uppercase tracking-[0.18em];
color: #172033;
}
.brand-caption {
@apply text-xs uppercase tracking-[0.24em];
color: #5d6b82;
}
.side-menu {
@apply flex flex-1 items-center justify-between gap-3;
@apply flex w-full flex-1 items-center justify-between gap-3;
}
.side-menu-items {

View File

@@ -2,15 +2,13 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import AppAvatar from '@/components/AppAvatar.vue';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
import { useLanguageStore } from '@/stores/languageStore.js';
import { useNotificationsStore } from '@/features/notifications/stores/notificationsStore.js';
import { getNotificationRoute } from '@/features/notifications/notificationRoutes.js';
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
import { useUserProfileStore } from '@/features/user-profile/stores/userProfileStore.js';
import SidebarUserMenu from './SidebarUserMenu.vue';
import {
mdiBellOutline,
mdiCalendarMonthOutline,
@@ -39,16 +37,12 @@
const authStore = useAuthStore();
const channelsStore = useChannelsStore();
const contentItemsStore = useContentItemsStore();
const languageStore = useLanguageStore();
const notificationsStore = useNotificationsStore();
const campaignsStore = useCampaignsStore();
const userProfileStore = useUserProfileStore();
const isUserMenuOpen = ref(false);
const isNotificationsOpen = ref(false);
const isSearchFocused = ref(false);
const searchQuery = ref('');
const userMenuRef = ref(null);
const notificationsRef = ref(null);
const searchRef = ref(null);
@@ -133,25 +127,6 @@
isNotificationsOpen.value = !isNotificationsOpen.value;
}
function toggleUserMenu() {
if (!props.isExpanded) {
return;
}
isUserMenuOpen.value = !isUserMenuOpen.value;
}
function toggleLanguage() {
const nextLocale = languageStore.locale === 'en' ? 'fr' : 'en';
languageStore.setLocale(nextLocale);
isUserMenuOpen.value = false;
}
async function openProfile() {
isUserMenuOpen.value = false;
await router.push({ name: 'settings-user-information' });
}
function formatNotificationTitle(notification) {
return notificationTitleMap.value[notification.eventType] ?? notification.message;
}
@@ -182,11 +157,6 @@
await router.push(result.route);
}
function handleLogout() {
isUserMenuOpen.value = false;
authStore.logout();
}
function handleDocumentClick(event) {
if (searchRef.value && !searchRef.value.contains(event.target)) {
isSearchFocused.value = false;
@@ -195,10 +165,6 @@
if (isNotificationsOpen.value && notificationsRef.value && !notificationsRef.value.contains(event.target)) {
isNotificationsOpen.value = false;
}
if (isUserMenuOpen.value && userMenuRef.value && !userMenuRef.value.contains(event.target)) {
isUserMenuOpen.value = false;
}
}
watch(
@@ -215,15 +181,6 @@
{ immediate: true }
);
watch(
() => props.isExpanded,
isExpanded => {
if (!isExpanded) {
isUserMenuOpen.value = false;
}
}
);
onMounted(() => {
document.addEventListener('click', handleDocumentClick);
});
@@ -234,11 +191,26 @@
</script>
<template>
<aside
<aside
class="app-sidebar"
:class="{ 'app-sidebar-collapsed': !isExpanded }"
>
<div class="app-sidebar-inner">
<div class="brand-block">
<router-link
class="brand-link"
:class="{ 'brand-link-collapsed': !isExpanded }"
to="/"
>
<span class="brand-mark">S</span>
<div v-if="isExpanded">
<div class="brand-name">Socialize</div>
<div class="brand-caption">{{ t('nav.brandCaption') }}</div>
</div>
</router-link>
</div>
<div class="app-sidebar-scroll">
<div
v-if="authStore.isAuthenticated"
class="sidebar-section sidebar-utilities"
@@ -578,75 +550,52 @@
</div>
</div>
<div
v-if="authStore.isAuthenticated"
ref="userMenuRef"
class="sidebar-workspace sidebar-workspace-bottom"
>
<button
class="sidebar-workspace-trigger"
type="button"
:title="!isExpanded ? userProfileStore.alias : null"
@click.stop="toggleUserMenu"
>
<AppAvatar
:name="userProfileStore.alias"
:src="userProfileStore.portraitUrl"
size="sm"
/>
<span
v-if="isExpanded"
class="sidebar-workspace-label"
>
{{ userProfileStore.alias }}
</span>
<v-icon
v-if="isExpanded"
:icon="mdiChevronDown"
class="sidebar-workspace-icon"
:class="{ 'sidebar-workspace-icon-open': isUserMenuOpen }"
/>
</button>
<div
v-if="isExpanded && isUserMenuOpen"
class="sidebar-workspace-menu"
>
<button
class="sidebar-workspace-option"
type="button"
@click="openProfile"
>
{{ t('nav.profile') }}
</button>
<button
class="sidebar-workspace-option"
type="button"
@click="toggleLanguage"
>
{{ t('nav.language') }}
</button>
<button
class="sidebar-workspace-option sidebar-workspace-option-danger"
type="button"
@click="handleLogout"
>
{{ t('nav.signOut') }}
</button>
</div>
</div>
</div>
<SidebarUserMenu
v-if="authStore.isAuthenticated"
:is-expanded="isExpanded"
/>
</aside>
</template>
<style scoped>
.app-sidebar {
@apply w-[19rem] flex-shrink-0 px-4 pb-4 pt-4 transition-[width,padding] duration-200 md:sticky md:top-24 md:h-[calc(100vh-6rem)] md:pt-0;
@apply flex h-full w-[19rem] flex-shrink-0 flex-col px-4 pt-4 transition-[width,padding] duration-200;
border-right: 1px solid rgba(23, 32, 51, 0.08);
}
.app-sidebar-inner {
@apply flex h-full flex-col gap-4 overflow-y-auto py-3 pr-3;
.app-sidebar-scroll {
@apply flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto pb-4 pt-3 pr-3;
}
.brand-block {
@apply flex items-center gap-3;
}
.brand-link {
@apply flex items-center gap-3 no-underline;
color: inherit;
}
.brand-mark {
@apply flex h-11 w-11 items-center justify-center rounded-2xl text-lg font-black;
background: linear-gradient(135deg, #ff8a3d 0%, #ef4444 100%);
color: #fffaf2;
}
.brand-name {
@apply text-lg font-black uppercase tracking-[0.18em];
color: #172033;
}
.brand-caption {
@apply text-xs uppercase tracking-[0.24em];
color: #5d6b82;
}
.side-menu {
@apply flex flex-1 items-center justify-between gap-3;
}
.sidebar-utilities {
@@ -776,63 +725,6 @@
color: #172033;
}
.sidebar-workspace {
@apply relative flex flex-col gap-2;
}
.sidebar-workspace-bottom {
@apply mt-auto pt-4;
border-top: 1px solid rgba(23, 32, 51, 0.08);
}
.sidebar-workspace-kicker {
@apply px-4 text-[10px] font-bold uppercase tracking-[0.22em];
color: #7a8799;
}
.sidebar-workspace-trigger {
@apply flex w-full items-center gap-3 rounded-[1.1rem] px-4 py-3 text-left transition-colors;
background: rgba(23, 32, 51, 0.04);
color: #172033;
}
.sidebar-workspace-trigger:hover {
background: rgba(23, 32, 51, 0.07);
}
.sidebar-workspace-label {
@apply flex-1 truncate text-sm font-semibold;
}
.sidebar-workspace-icon {
@apply text-base transition-transform;
color: #5d6b82;
}
.sidebar-workspace-icon-open {
transform: rotate(180deg);
}
.sidebar-workspace-menu {
@apply absolute bottom-[calc(100%+0.5rem)] left-0 right-0 z-30 flex flex-col gap-1 rounded-[1.25rem] border p-2;
background: rgba(255, 255, 255, 0.98);
border-color: rgba(23, 32, 51, 0.08);
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.12);
}
.sidebar-workspace-option {
@apply rounded-[0.95rem] px-4 py-3 text-left text-sm font-semibold transition-colors;
color: #172033;
}
.sidebar-workspace-option:hover {
background: rgba(23, 32, 51, 0.05);
}
.sidebar-workspace-option-danger {
color: #b91c1c;
}
.sidebar-section {
@apply flex flex-col gap-2;
}

View File

@@ -0,0 +1,187 @@
<script setup>
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import AppAvatar from '@/components/AppAvatar.vue';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { useLanguageStore } from '@/stores/languageStore.js';
import { useUserProfileStore } from '@/features/user-profile/stores/userProfileStore.js';
import { mdiChevronDown } from '@mdi/js';
const props = defineProps({
isExpanded: {
type: Boolean,
default: true,
},
});
const router = useRouter();
const { t } = useI18n();
const authStore = useAuthStore();
const languageStore = useLanguageStore();
const userProfileStore = useUserProfileStore();
const isUserMenuOpen = ref(false);
const userMenuRef = ref(null);
function toggleUserMenu() {
if (!props.isExpanded) {
return;
}
isUserMenuOpen.value = !isUserMenuOpen.value;
}
function toggleLanguage() {
const nextLocale = languageStore.locale === 'en' ? 'fr' : 'en';
languageStore.setLocale(nextLocale);
isUserMenuOpen.value = false;
}
async function openProfile() {
isUserMenuOpen.value = false;
await router.push({ name: 'settings-user-information' });
}
function handleLogout() {
isUserMenuOpen.value = false;
authStore.logout();
}
function handleDocumentClick(event) {
if (isUserMenuOpen.value && userMenuRef.value && !userMenuRef.value.contains(event.target)) {
isUserMenuOpen.value = false;
}
}
watch(
() => props.isExpanded,
isExpanded => {
if (!isExpanded) {
isUserMenuOpen.value = false;
}
}
);
onMounted(() => {
document.addEventListener('click', handleDocumentClick);
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleDocumentClick);
});
</script>
<template>
<div
ref="userMenuRef"
class="sidebar-workspace sidebar-workspace-bottom"
>
<button
class="sidebar-workspace-trigger"
type="button"
:title="!isExpanded ? userProfileStore.alias : null"
@click.stop="toggleUserMenu"
>
<AppAvatar
:name="userProfileStore.alias"
:src="userProfileStore.portraitUrl"
size="sm"
/>
<span
v-if="isExpanded"
class="sidebar-workspace-label"
>
{{ userProfileStore.alias }}
</span>
<v-icon
v-if="isExpanded"
:icon="mdiChevronDown"
class="sidebar-workspace-icon"
:class="{ 'sidebar-workspace-icon-open': isUserMenuOpen }"
/>
</button>
<div
v-if="isExpanded && isUserMenuOpen"
class="sidebar-workspace-menu"
>
<button
class="sidebar-workspace-option"
type="button"
@click="openProfile"
>
{{ t('nav.profile') }}
</button>
<button
class="sidebar-workspace-option"
type="button"
@click="toggleLanguage"
>
{{ t('nav.language') }}
</button>
<button
class="sidebar-workspace-option sidebar-workspace-option-danger"
type="button"
@click="handleLogout"
>
{{ t('nav.signOut') }}
</button>
</div>
</div>
</template>
<style scoped>
.sidebar-workspace {
@apply relative flex flex-col gap-2;
}
.sidebar-workspace-bottom {
@apply py-4;
border-top: 1px solid rgba(23, 32, 51, 0.08);
}
.sidebar-workspace-trigger {
@apply flex w-full items-center gap-3 rounded-[1.1rem] px-4 py-3 text-left transition-colors;
background: rgba(23, 32, 51, 0.04);
color: #172033;
}
.sidebar-workspace-trigger:hover {
background: rgba(23, 32, 51, 0.07);
}
.sidebar-workspace-label {
@apply flex-1 truncate text-sm font-semibold;
}
.sidebar-workspace-icon {
@apply text-base transition-transform;
color: #5d6b82;
}
.sidebar-workspace-icon-open {
transform: rotate(180deg);
}
.sidebar-workspace-menu {
@apply absolute bottom-[calc(100%+0.5rem)] left-0 right-0 z-30 flex flex-col gap-1 rounded-[1.25rem] border p-2;
isolation: isolate;
background: #fffdf8;
background-clip: padding-box;
border-color: rgba(23, 32, 51, 0.08);
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.12);
}
.sidebar-workspace-option {
@apply rounded-[0.95rem] px-4 py-3 text-left text-sm font-semibold transition-colors;
color: #172033;
}
.sidebar-workspace-option:hover {
background: rgba(23, 32, 51, 0.05);
}
.sidebar-workspace-option-danger {
color: #b91c1c;
}
</style>

View File

@@ -4,26 +4,59 @@
import { useI18n } from 'vue-i18n';
import AppAvatar from '@/components/AppAvatar.vue';
import { useAuthStore } from '@/features/auth/stores/authStore.js';
import {
organizationPermissions,
useOrganizationStore,
} from '@/features/organizations/stores/organizationStore.js';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
import {
mdiChevronDown,
mdiCogOutline,
mdiPlus,
mdiSwapHorizontal,
} from '@mdi/js';
const router = useRouter();
const { t } = useI18n();
const workspaceStore = useWorkspaceStore();
const organizationStore = useOrganizationStore();
const authStore = useAuthStore();
const isWorkspaceMenuOpen = ref(false);
const isOrganizationListOpen = ref(false);
const workspaceMenuRef = ref(null);
const canSwitchWorkspaces = computed(() => workspaceStore.workspaces.length > 1);
const canManageWorkspaces = computed(() => authStore.isManager);
const canOpenWorkspaceMenu = computed(() => canSwitchWorkspaces.value || canManageWorkspaces.value);
const activeOrganization = computed(() => organizationStore.activeOrganization);
const visibleWorkspaces = computed(() => {
if (!organizationStore.selectedOrganizationId) {
return workspaceStore.workspaces;
}
return workspaceStore.workspaces.filter(
workspace => workspace.organizationId === organizationStore.selectedOrganizationId
);
});
const canSwitchWorkspaces = computed(() => visibleWorkspaces.value.length > 1);
const canSwitchOrganizations = computed(() => organizationStore.organizations.length > 1);
const switchableOrganizations = computed(() =>
organizationStore.organizations.filter(
organization => organization.id !== organizationStore.selectedOrganizationId
)
);
const canManageWorkspaces = computed(() =>
activeOrganization.value?.currentUserPermissions?.includes(organizationPermissions.createWorkspaces) ||
activeOrganization.value?.currentUserPermissions?.includes(organizationPermissions.manageWorkspaces) ||
authStore.isManager
);
const canOpenWorkspaceMenu = computed(() =>
canSwitchWorkspaces.value || canSwitchOrganizations.value || canManageWorkspaces.value || Boolean(activeOrganization.value)
);
const activeWorkspaceName = computed(() =>
workspaceStore.activeWorkspace?.name || t('nav.noWorkspace')
);
const activeOrganizationName = computed(() =>
activeOrganization.value?.name || t('workspaceSelector.noOrganization')
);
function toggleWorkspaceMenu() {
if (!canOpenWorkspaceMenu.value) {
@@ -31,21 +64,45 @@
}
isWorkspaceMenuOpen.value = !isWorkspaceMenuOpen.value;
isOrganizationListOpen.value = false;
}
function chooseWorkspace(workspaceId) {
workspaceStore.setActiveWorkspace(workspaceId);
isWorkspaceMenuOpen.value = false;
isOrganizationListOpen.value = false;
}
function chooseOrganization(organizationId) {
organizationStore.setSelectedOrganization(organizationId);
const nextWorkspace = workspaceStore.workspaces.find(
workspace => workspace.organizationId === organizationId
);
workspaceStore.setActiveWorkspace(nextWorkspace?.id ?? null);
isOrganizationListOpen.value = false;
}
function toggleOrganizationList() {
isOrganizationListOpen.value = !isOrganizationListOpen.value;
}
async function openCreateWorkspace() {
isWorkspaceMenuOpen.value = false;
isOrganizationListOpen.value = false;
await router.push({ name: 'workspace-create' });
}
async function openOrganizationSettings(organizationId) {
isWorkspaceMenuOpen.value = false;
isOrganizationListOpen.value = false;
await router.push({ name: 'organization-settings', params: { organizationId } });
}
function handleDocumentClick(event) {
if (workspaceMenuRef.value && !workspaceMenuRef.value.contains(event.target)) {
isWorkspaceMenuOpen.value = false;
isOrganizationListOpen.value = false;
}
}
@@ -87,7 +144,7 @@
class="user-menu"
>
<button
v-for="workspace in workspaceStore.workspaces"
v-for="workspace in visibleWorkspaces"
:key="workspace.id"
class="user-menu-item"
:class="{ 'user-menu-item-active': workspace.id === workspaceStore.activeWorkspaceId }"
@@ -113,6 +170,59 @@
<span>{{ t('workspaceSelector.createAction') }}</span>
<v-icon :icon="mdiPlus" />
</button>
<div
v-if="activeOrganization"
class="organization-switcher"
>
<div class="organization-current-row">
<button
class="user-menu-item organization-current"
type="button"
@click="openOrganizationSettings(activeOrganization.id)"
>
<span class="organization-mark">{{ activeOrganization.name.slice(0, 1).toUpperCase() }}</span>
<span class="user-menu-item-copy">
<span>{{ activeOrganizationName }}</span>
<small>{{ t('workspaceSelector.organizationLabel') }}</small>
</span>
<v-icon
:icon="mdiCogOutline"
class="organization-action-icon"
/>
</button>
</div>
<button
v-if="canSwitchOrganizations"
class="organization-swap-button"
type="button"
:aria-expanded="isOrganizationListOpen"
@click="toggleOrganizationList"
>
<span>Change organization</span>
<v-icon :icon="mdiSwapHorizontal" />
</button>
<div
v-if="isOrganizationListOpen"
class="organization-options"
>
<button
v-for="organization in switchableOrganizations"
:key="organization.id"
class="user-menu-item organization-option"
type="button"
@click="chooseOrganization(organization.id)"
>
<span class="organization-mark">{{ organization.name.slice(0, 1).toUpperCase() }}</span>
<span class="user-menu-item-copy">
<span>{{ organization.name }}</span>
<small>{{ t('workspaceSelector.organizationLabel') }}</small>
</span>
</button>
</div>
</div>
</div>
</div>
</template>
@@ -160,8 +270,10 @@
}
.user-menu {
@apply absolute right-0 top-[calc(100%+0.75rem)] flex min-w-[14rem] flex-col gap-1 rounded-[1.25rem] border p-2;
background: rgba(255, 255, 255, 0.96);
@apply absolute right-0 top-[calc(100%+0.75rem)] flex max-h-[80vh] min-w-[17rem] flex-col gap-1 overflow-y-auto rounded-[1.25rem] border p-2;
isolation: isolate;
background: #fffdf8;
background-clip: padding-box;
border-color: rgba(23, 32, 51, 0.08);
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.12);
z-index: 40;
@@ -199,4 +311,49 @@
@apply justify-between border border-dashed;
border-color: rgba(23, 32, 51, 0.12);
}
.organization-switcher {
@apply mt-2 flex flex-col gap-1 border-t pt-2;
border-color: rgba(23, 32, 51, 0.08);
}
.organization-current-row {
@apply flex w-full min-w-0 flex-col gap-1;
}
.organization-current {
@apply w-full min-w-0 border;
background: rgba(23, 32, 51, 0.04);
border-color: rgba(23, 32, 51, 0.08);
}
.organization-current:hover {
background: rgba(23, 32, 51, 0.07);
}
.organization-mark {
@apply flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-[0.8rem] text-xs font-black;
background: rgba(23, 32, 51, 0.08);
color: #172033;
}
.organization-action-icon {
@apply flex-shrink-0 text-base;
color: #526178;
}
.organization-swap-button {
@apply flex w-full items-center justify-between gap-3 rounded-[0.9rem] border px-3 py-2.5 text-sm font-semibold transition-colors;
background: rgba(23, 32, 51, 0.04);
border-color: rgba(23, 32, 51, 0.08);
color: #172033;
}
.organization-swap-button:hover {
background: rgba(23, 32, 51, 0.08);
}
.organization-options {
@apply flex flex-col gap-1;
}
</style>

View File

@@ -39,7 +39,10 @@
"saving": "Saving..."
},
"workspaceSelector": {
"createAction": "Add workspace"
"createAction": "Add workspace",
"organizationLabel": "Organization",
"organizationSettings": "Organization settings",
"noOrganization": "No organization"
},
"workspaceCreate": {
"eyebrow": "Workspace",
@@ -54,6 +57,7 @@
"fields": {
"name": "Workspace name",
"namePlaceholder": "Northwind Studio",
"organization": "Organization",
"slug": "Workspace slug",
"slugPlaceholder": "northwind-studio",
"timeZone": "Time zone"
@@ -63,6 +67,64 @@
"createFailed": "The workspace could not be created."
}
},
"organizationSettings": {
"eyebrow": "Organization",
"title": "Organization settings",
"description": "Manage the SaaS account boundary for members, billing access, connections, and owned workspaces.",
"loading": "Loading organization settings...",
"fields": {
"name": "Name",
"createdAt": "Created"
},
"sections": {
"profile": {
"title": "Profile",
"description": "The account identity used across organization-owned workspaces."
},
"members": {
"title": "Members",
"description": "Organization-level users and their inherited account permissions.",
"empty": "No organization members found."
},
"billing": {
"title": "Billing",
"description": "Subscription and billing access for this organization.",
"placeholderTitle": "Billing provider is not connected yet",
"placeholderText": "Plan, payment, and invoice management will live here after billing integration is added."
},
"connections": {
"title": "Connections",
"description": "Organization-level connectors and data mappings.",
"placeholderTitle": "No organization connections configured",
"placeholderText": "Connector authorization flows are intentionally out of scope for this UI shell."
},
"workspaces": {
"title": "Workspaces",
"description": "Brand and client workspaces owned by this organization.",
"empty": "No workspaces belong to this organization yet."
}
},
"roles": {
"Owner": "Owner",
"Admin": "Admin",
"BillingManager": "Billing manager",
"ConnectorManager": "Connector manager",
"Member": "Member"
},
"permissions": {
"title": "Your permissions",
"description": "Current organization permissions returned by the API.",
"items": {
"ManageOrganizationSettings": "Manage settings",
"ManageOrganizationMembers": "Manage members",
"CreateWorkspaces": "Create workspaces",
"ManageWorkspaces": "Manage workspaces",
"ManageBilling": "Manage billing",
"ManageConnectors": "Manage connectors",
"AccessOwnedWorkspaces": "Access owned workspaces"
}
}
},
"nav": {
"brandCaption": "Approval workflow",
"workspace": "Workspace",

View File

@@ -39,7 +39,10 @@
"saving": "Enregistrement..."
},
"workspaceSelector": {
"createAction": "Ajouter un espace"
"createAction": "Ajouter un espace",
"organizationLabel": "Organisation",
"organizationSettings": "Parametres de l'organisation",
"noOrganization": "Aucune organisation"
},
"workspaceCreate": {
"eyebrow": "Espace",
@@ -54,6 +57,7 @@
"fields": {
"name": "Nom de l'espace",
"namePlaceholder": "Northwind Studio",
"organization": "Organisation",
"slug": "Slug de l'espace",
"slugPlaceholder": "northwind-studio",
"timeZone": "Fuseau horaire"
@@ -63,6 +67,64 @@
"createFailed": "L'espace n'a pas pu etre cree."
}
},
"organizationSettings": {
"eyebrow": "Organisation",
"title": "Parametres de l'organisation",
"description": "Gerez le compte SaaS pour les membres, la facturation, les connexions et les espaces detenus.",
"loading": "Chargement des parametres de l'organisation...",
"fields": {
"name": "Nom",
"createdAt": "Cree"
},
"sections": {
"profile": {
"title": "Profil",
"description": "L'identite du compte utilisee dans les espaces detenus par l'organisation."
},
"members": {
"title": "Membres",
"description": "Utilisateurs de l'organisation et leurs permissions heritees.",
"empty": "Aucun membre d'organisation trouve."
},
"billing": {
"title": "Facturation",
"description": "Acces a l'abonnement et a la facturation de cette organisation.",
"placeholderTitle": "Le fournisseur de facturation n'est pas encore connecte",
"placeholderText": "La gestion du forfait, des paiements et des factures sera ajoutee ici apres l'integration de facturation."
},
"connections": {
"title": "Connexions",
"description": "Connecteurs et regles de donnees au niveau de l'organisation.",
"placeholderTitle": "Aucune connexion d'organisation configuree",
"placeholderText": "Les flux d'autorisation des connecteurs sont volontairement hors portee de cette interface."
},
"workspaces": {
"title": "Espaces",
"description": "Espaces de marque et de client detenus par cette organisation.",
"empty": "Aucun espace n'appartient encore a cette organisation."
}
},
"roles": {
"Owner": "Proprietaire",
"Admin": "Administrateur",
"BillingManager": "Gestionnaire facturation",
"ConnectorManager": "Gestionnaire connecteurs",
"Member": "Membre"
},
"permissions": {
"title": "Vos permissions",
"description": "Permissions d'organisation retournees par l'API.",
"items": {
"ManageOrganizationSettings": "Gerer les parametres",
"ManageOrganizationMembers": "Gerer les membres",
"CreateWorkspaces": "Creer des espaces",
"ManageWorkspaces": "Gerer les espaces",
"ManageBilling": "Gerer la facturation",
"ManageConnectors": "Gerer les connecteurs",
"AccessOwnedWorkspaces": "Acceder aux espaces detenus"
}
}
},
"nav": {
"brandCaption": "Flux d'approbation",
"workspace": "Espace de travail",

View File

@@ -34,6 +34,7 @@ import { useNotificationsStore } from '@/features/notifications/stores/notificat
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
import { i18n } from '@/plugins/i18n.js';
import config from '@/config.js';
import { createHead } from '@vueuse/head';
const vuetify = createVuetify({
components: {
@@ -78,9 +79,11 @@ const vuetify = createVuetify({
});
const pinia = createPinia();
const head = createHead();
const app = createApp(App)
.use(pinia)
.use(head)
.use(vuetify)
.use(router)
.use(i18n)

Some files were not shown because too many files have changed in this diff Show More