Compare commits
10 Commits
2d472892d6
...
664eb07201
| Author | SHA1 | Date | |
|---|---|---|---|
| 664eb07201 | |||
| 58c1301054 | |||
| 552f4f1f21 | |||
| 8f4b95f311 | |||
| 4fba72e99c | |||
| 55d8acef4c | |||
| 7d3f495472 | |||
| 802668fb0b | |||
| cd6f402d9e | |||
| 9bdef978bd |
@@ -70,6 +70,7 @@ Update OpenAPI:
|
|||||||
## Current Domain Modules
|
## Current Domain Modules
|
||||||
|
|
||||||
- `Identity`: authentication, refresh tokens, email verification, password reset, social login.
|
- `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.
|
- `Workspaces`: workspace membership, workspace settings, access scoping.
|
||||||
- `Clients`: client records and primary contacts tied to workspaces.
|
- `Clients`: client records and primary contacts tied to workspaces.
|
||||||
- `Projects`: project pipeline and client/project relationships.
|
- `Projects`: project pipeline and client/project relationships.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Socialize
|
# 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.
|
It is not a public social network. The product is for internal teams, providers, and client approvers coordinating content work before publication.
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using Socialize.Api.Modules.Feedback.Data;
|
|||||||
using Socialize.Api.Modules.Identity.Data;
|
using Socialize.Api.Modules.Identity.Data;
|
||||||
using Socialize.Api.Modules.Notifications.Data;
|
using Socialize.Api.Modules.Notifications.Data;
|
||||||
using Socialize.Api.Modules.Campaigns.Data;
|
using Socialize.Api.Modules.Campaigns.Data;
|
||||||
|
using Socialize.Api.Modules.Organizations.Data;
|
||||||
using Socialize.Api.Modules.Workspaces.Data;
|
using Socialize.Api.Modules.Workspaces.Data;
|
||||||
|
|
||||||
namespace Socialize.Api.Data;
|
namespace Socialize.Api.Data;
|
||||||
@@ -17,6 +18,8 @@ public class AppDbContext(
|
|||||||
DbContextOptions<AppDbContext> options)
|
DbContextOptions<AppDbContext> options)
|
||||||
: IdentityDbContext<User, Role, Guid>(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<Workspace> Workspaces => Set<Workspace>();
|
||||||
public DbSet<WorkspaceInvite> WorkspaceInvites => Set<WorkspaceInvite>();
|
public DbSet<WorkspaceInvite> WorkspaceInvites => Set<WorkspaceInvite>();
|
||||||
public DbSet<Client> Clients => Set<Client>();
|
public DbSet<Client> Clients => Set<Client>();
|
||||||
@@ -41,6 +44,7 @@ public class AppDbContext(
|
|||||||
{
|
{
|
||||||
base.OnModelCreating(builder);
|
base.OnModelCreating(builder);
|
||||||
|
|
||||||
|
builder.ConfigureOrganizationsModule();
|
||||||
builder.ConfigureWorkspacesModule();
|
builder.ConfigureWorkspacesModule();
|
||||||
builder.ConfigureClientsModule();
|
builder.ConfigureClientsModule();
|
||||||
builder.ConfigureCampaignsModule();
|
builder.ConfigureCampaignsModule();
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ using Socialize.Api.Modules.ContentItems.Data;
|
|||||||
using Socialize.Api.Modules.Clients.Data;
|
using Socialize.Api.Modules.Clients.Data;
|
||||||
using Socialize.Api.Modules.Notifications.Data;
|
using Socialize.Api.Modules.Notifications.Data;
|
||||||
using Socialize.Api.Modules.Campaigns.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 Socialize.Api.Modules.Workspaces.Data;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
@@ -19,6 +21,7 @@ namespace Socialize.Api.Infrastructure.Development;
|
|||||||
|
|
||||||
public static class DevelopmentSeedExtensions
|
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 WorkspaceId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||||
private static readonly Guid ScopedClientId = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
private static readonly Guid ScopedClientId = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||||
private static readonly Guid HiddenClientId = Guid.Parse("22222222-2222-2222-2222-333333333333");
|
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(
|
await EnsureWorkspaceDataAsync(
|
||||||
manager.Id,
|
manager.Id,
|
||||||
clientUser.Id,
|
clientUser.Id,
|
||||||
@@ -224,6 +233,75 @@ public static class DevelopmentSeedExtensions
|
|||||||
return user;
|
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(
|
private static async Task EnsureWorkspaceDataAsync(
|
||||||
Guid managerUserId,
|
Guid managerUserId,
|
||||||
Guid clientUserId,
|
Guid clientUserId,
|
||||||
@@ -248,6 +326,7 @@ public static class DevelopmentSeedExtensions
|
|||||||
|
|
||||||
workspace.Name = "Northstar Studio";
|
workspace.Name = "Northstar Studio";
|
||||||
workspace.Slug = "northstar-studio";
|
workspace.Slug = "northstar-studio";
|
||||||
|
workspace.OrganizationId = OrganizationId;
|
||||||
workspace.OwnerUserId = managerUserId;
|
workspace.OwnerUserId = managerUserId;
|
||||||
workspace.TimeZone = "America/Montreal";
|
workspace.TimeZone = "America/Montreal";
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Socialize.Api.Modules.Identity.Contracts;
|
using Socialize.Api.Modules.Identity.Contracts;
|
||||||
|
using Socialize.Api.Modules.Organizations.Services;
|
||||||
|
|
||||||
namespace Socialize.Api.Infrastructure.Security;
|
namespace Socialize.Api.Infrastructure.Security;
|
||||||
|
|
||||||
public sealed class AccessScopeService
|
public sealed class AccessScopeService(
|
||||||
|
OrganizationAccessService organizationAccessService)
|
||||||
{
|
{
|
||||||
public bool IsManager(ClaimsPrincipal user)
|
public bool IsManager(ClaimsPrincipal user)
|
||||||
{
|
{
|
||||||
@@ -53,4 +55,123 @@ public sealed class AccessScopeService
|
|||||||
|| IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId)
|
|| IsProvider(user) && CanAccessCampaign(user, workspaceId, clientId, campaignId)
|
||||||
|| IsClient(user) && CanAccessClient(user, workspaceId, clientId);
|
|| 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1506
backend/src/Socialize.Api/Migrations/20260504195518_AddOrganizations.Designer.cs
generated
Normal file
1506
backend/src/Socialize.Api/Migrations/20260504195518_AddOrganizations.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1203,6 +1203,66 @@ namespace Socialize.Api.Migrations
|
|||||||
b.ToTable("NotificationEvents", (string)null);
|
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 =>
|
modelBuilder.Entity("Socialize.Api.Modules.Workspaces.Data.Workspace", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1235,6 +1295,9 @@ namespace Socialize.Api.Migrations
|
|||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("character varying(256)");
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<Guid>("OrganizationId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<Guid>("OwnerUserId")
|
b.Property<Guid>("OwnerUserId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
@@ -1260,6 +1323,8 @@ namespace Socialize.Api.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("OrganizationId");
|
||||||
|
|
||||||
b.HasIndex("OwnerUserId");
|
b.HasIndex("OwnerUserId");
|
||||||
|
|
||||||
b.HasIndex("Slug")
|
b.HasIndex("Slug")
|
||||||
@@ -1404,6 +1469,24 @@ namespace Socialize.Api.Migrations
|
|||||||
b.Navigation("FeedbackReport");
|
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 =>
|
modelBuilder.Entity("Socialize.Api.Modules.Feedback.Data.FeedbackReport", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("ActivityEntries");
|
b.Navigation("ActivityEntries");
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -61,7 +61,7 @@ public class GetApprovalsHandler(
|
|||||||
return;
|
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);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ namespace Socialize.Api.Modules.Approvals.Handlers;
|
|||||||
|
|
||||||
public record SubmitApprovalDecisionRequest(
|
public record SubmitApprovalDecisionRequest(
|
||||||
string Decision,
|
string Decision,
|
||||||
string? Comment,
|
|
||||||
string? ReviewerName,
|
string? ReviewerName,
|
||||||
string? ReviewerEmail);
|
string? ReviewerEmail);
|
||||||
|
|
||||||
@@ -25,7 +24,6 @@ public class SubmitApprovalDecisionRequestValidator
|
|||||||
.NotEmpty()
|
.NotEmpty()
|
||||||
.Equal("Approved")
|
.Equal("Approved")
|
||||||
.WithMessage("Only approved decisions are supported.");
|
.WithMessage("Only approved decisions are supported.");
|
||||||
RuleFor(x => x.Comment).MaximumLength(2048);
|
|
||||||
RuleFor(x => x.ReviewerName).MaximumLength(256);
|
RuleFor(x => x.ReviewerName).MaximumLength(256);
|
||||||
RuleFor(x => x.ReviewerEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.ReviewerEmail));
|
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 &&
|
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);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
return;
|
||||||
@@ -90,7 +88,7 @@ public class SubmitApprovalDecisionHandler(
|
|||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
ApprovalRequestId = approval.Id,
|
ApprovalRequestId = approval.Id,
|
||||||
Decision = normalizedDecision,
|
Decision = normalizedDecision,
|
||||||
Comment = string.IsNullOrWhiteSpace(request.Comment) ? null : request.Comment.Trim(),
|
Comment = null,
|
||||||
DecidedByUserId = User?.Identity?.IsAuthenticated == true ? User.GetUserId() : null,
|
DecidedByUserId = User?.Identity?.IsAuthenticated == true ? User.GetUserId() : null,
|
||||||
DecidedByName = decidedByName,
|
DecidedByName = decidedByName,
|
||||||
DecidedByEmail = decidedByEmail,
|
DecidedByEmail = decidedByEmail,
|
||||||
|
|||||||
@@ -12,11 +12,6 @@ public static class ApprovalModes
|
|||||||
|
|
||||||
public static class ApprovalWorkflowRules
|
public static class ApprovalWorkflowRules
|
||||||
{
|
{
|
||||||
public static bool CanCreateSingleStepApprovalRequest(string approvalMode)
|
|
||||||
{
|
|
||||||
return approvalMode is ApprovalModes.Optional or ApprovalModes.Required;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool BlocksManualApprovedOrScheduledStatus(string approvalMode)
|
public static bool BlocksManualApprovedOrScheduledStatus(string approvalMode)
|
||||||
{
|
{
|
||||||
return approvalMode is ApprovalModes.Required or ApprovalModes.MultiLevel;
|
return approvalMode is ApprovalModes.Required or ApprovalModes.MultiLevel;
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ public class CreateAssetRevisionHandler(
|
|||||||
.SingleOrDefaultAsync(candidate => candidate.Id == asset.ContentItemId, ct);
|
.SingleOrDefaultAsync(candidate => candidate.Id == asset.ContentItemId, ct);
|
||||||
|
|
||||||
if (contentItem is not null &&
|
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);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ public class CreateGoogleDriveAssetHandler(
|
|||||||
return;
|
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);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ public class GetAssetsHandler(
|
|||||||
return;
|
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);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ public class CreateCampaignHandler(
|
|||||||
|
|
||||||
public override async Task HandleAsync(CreateCampaignRequest request, CancellationToken ct)
|
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);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -34,16 +34,9 @@ public class GetCampaignsHandler(
|
|||||||
{
|
{
|
||||||
IQueryable<Campaign> query = dbContext.Campaigns.AsQueryable();
|
IQueryable<Campaign> query = dbContext.Campaigns.AsQueryable();
|
||||||
|
|
||||||
if (accessScopeService.IsManager(User))
|
if (!accessScopeService.IsManager(User))
|
||||||
{
|
{
|
||||||
if (request.WorkspaceId.HasValue)
|
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
|
||||||
{
|
|
||||||
query = query.Where(campaign => campaign.WorkspaceId == request.WorkspaceId.Value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
|
|
||||||
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
|
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
|
||||||
IReadOnlyCollection<Guid> campaignScopeIds = User.GetCampaignScopeIds();
|
IReadOnlyCollection<Guid> campaignScopeIds = User.GetCampaignScopeIds();
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ public class ChangeClientPortraitHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!accessScopeService.CanManageWorkspace(User, client.WorkspaceId))
|
if (!await accessScopeService.CanManageWorkspaceAsync(User, client.WorkspaceId, ct))
|
||||||
{
|
{
|
||||||
await SendForbiddenAsync(ct);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ public class CreateClientHandler(
|
|||||||
|
|
||||||
public override async Task HandleAsync(CreateClientRequest request, CancellationToken ct)
|
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);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -33,16 +33,9 @@ public class GetClientsHandler(
|
|||||||
{
|
{
|
||||||
IQueryable<Client> query = dbContext.Clients.AsQueryable();
|
IQueryable<Client> query = dbContext.Clients.AsQueryable();
|
||||||
|
|
||||||
if (accessScopeService.IsManager(User))
|
if (!accessScopeService.IsManager(User))
|
||||||
{
|
{
|
||||||
if (request.WorkspaceId.HasValue)
|
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
|
||||||
{
|
|
||||||
query = query.Where(client => client.WorkspaceId == request.WorkspaceId.Value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
|
|
||||||
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
|
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
|
||||||
|
|
||||||
query = query.Where(client => workspaceScopeIds.Contains(client.WorkspaceId));
|
query = query.Where(client => workspaceScopeIds.Contains(client.WorkspaceId));
|
||||||
@@ -52,10 +45,11 @@ public class GetClientsHandler(
|
|||||||
query = query.Where(client => clientScopeIds.Contains(client.Id));
|
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
|
List<ClientDto> clients = await query
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ public class UpdateClientHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!accessScopeService.CanManageWorkspace(User, client.WorkspaceId))
|
if (!await accessScopeService.CanManageWorkspaceAsync(User, client.WorkspaceId, ct))
|
||||||
{
|
{
|
||||||
await SendForbiddenAsync(ct);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ public class CreateCommentHandler(
|
|||||||
return;
|
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);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ public class GetCommentsHandler(
|
|||||||
return;
|
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);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ public class ResolveCommentHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool canResolve = accessScopeService.CanManageWorkspace(User, comment.WorkspaceId)
|
bool canResolve = await accessScopeService.CanManageWorkspaceAsync(User, comment.WorkspaceId, ct)
|
||||||
|| accessScopeService.CanContributeToCampaign(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId);
|
|| await accessScopeService.CanContributeToCampaignAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct);
|
||||||
|
|
||||||
if (!canResolve)
|
if (!canResolve)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ public class CreateContentItemHandler(
|
|||||||
|
|
||||||
public override async Task HandleAsync(CreateContentItemRequest request, CancellationToken ct)
|
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);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ public class CreateContentItemRevisionHandler(
|
|||||||
return;
|
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);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ public class GetContentItemHandler(
|
|||||||
return;
|
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);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ public class GetContentItemRevisionsHandler(
|
|||||||
return;
|
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);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public class GetContentItemsHandler(
|
|||||||
|
|
||||||
if (!accessScopeService.IsManager(User))
|
if (!accessScopeService.IsManager(User))
|
||||||
{
|
{
|
||||||
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
|
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
|
||||||
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
|
IReadOnlyCollection<Guid> clientScopeIds = User.GetClientScopeIds();
|
||||||
IReadOnlyCollection<Guid> campaignScopeIds = User.GetCampaignScopeIds();
|
IReadOnlyCollection<Guid> campaignScopeIds = User.GetCampaignScopeIds();
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ public class UpdateContentItemStatusHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!accessScopeService.CanManageWorkspace(User, item.WorkspaceId))
|
if (!await accessScopeService.CanManageWorkspaceAsync(User, item.WorkspaceId, ct))
|
||||||
{
|
{
|
||||||
await SendForbiddenAsync(ct);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ public class GetNotificationsHandler(
|
|||||||
return;
|
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);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
return;
|
||||||
@@ -58,7 +58,7 @@ public class GetNotificationsHandler(
|
|||||||
|
|
||||||
if (!accessScopeService.IsManager(User))
|
if (!accessScopeService.IsManager(User))
|
||||||
{
|
{
|
||||||
IReadOnlyCollection<Guid> workspaceScopeIds = User.GetWorkspaceScopeIds();
|
IReadOnlyCollection<Guid> workspaceScopeIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
|
||||||
query = query.Where(notificationEvent =>
|
query = query.Where(notificationEvent =>
|
||||||
workspaceScopeIds.Contains(notificationEvent.WorkspaceId) ||
|
workspaceScopeIds.Contains(notificationEvent.WorkspaceId) ||
|
||||||
notificationEvent.RecipientUserId == currentUserId);
|
notificationEvent.RecipientUserId == currentUserId);
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ public class MarkNotificationAsReadHandler(
|
|||||||
|
|
||||||
Guid currentUserId = User.GetUserId();
|
Guid currentUserId = User.GetUserId();
|
||||||
bool canReadRecipientNotification = notificationEvent.RecipientUserId == currentUserId;
|
bool canReadRecipientNotification = notificationEvent.RecipientUserId == currentUserId;
|
||||||
if (!canReadRecipientNotification && !accessScopeService.CanAccessWorkspace(User, notificationEvent.WorkspaceId))
|
if (!canReadRecipientNotification && !await accessScopeService.CanAccessWorkspaceAsync(User, notificationEvent.WorkspaceId, ct))
|
||||||
{
|
{
|
||||||
await SendForbiddenAsync(ct);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ public class Workspace
|
|||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public required string Slug { get; set; }
|
public required string Slug { get; set; }
|
||||||
public string? LogoUrl { get; set; }
|
public string? LogoUrl { get; set; }
|
||||||
|
public Guid OrganizationId { get; set; }
|
||||||
public Guid OwnerUserId { get; set; }
|
public Guid OwnerUserId { get; set; }
|
||||||
public required string TimeZone { get; set; }
|
public required string TimeZone { get; set; }
|
||||||
public string ApprovalMode { get; set; } = "Required";
|
public string ApprovalMode { get; set; } = "Required";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Socialize.Api.Modules.Organizations.Data;
|
||||||
|
|
||||||
namespace Socialize.Api.Modules.Workspaces.Data;
|
namespace Socialize.Api.Modules.Workspaces.Data;
|
||||||
|
|
||||||
@@ -22,7 +23,12 @@ public static class WorkspaceModelConfiguration
|
|||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
.HasDefaultValueSql("CURRENT_TIMESTAMP");
|
||||||
workspace.HasIndex(x => x.Slug).IsUnique();
|
workspace.HasIndex(x => x.Slug).IsUnique();
|
||||||
|
workspace.HasIndex(x => x.OrganizationId);
|
||||||
workspace.HasIndex(x => x.OwnerUserId);
|
workspace.HasIndex(x => x.OwnerUserId);
|
||||||
|
workspace.HasOne<Organization>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.OrganizationId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<WorkspaceInvite>(workspaceInvite =>
|
modelBuilder.Entity<WorkspaceInvite>(workspaceInvite =>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ public class ChangeWorkspaceLogoHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!accessScopeService.CanManageWorkspace(User, workspace.Id))
|
if (!await accessScopeService.CanManageWorkspaceAsync(User, workspace.Id, ct))
|
||||||
{
|
{
|
||||||
await SendForbiddenAsync(ct);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Socialize.Api.Modules.Workspaces.Data;
|
|||||||
namespace Socialize.Api.Modules.Workspaces.Handlers;
|
namespace Socialize.Api.Modules.Workspaces.Handlers;
|
||||||
|
|
||||||
public record CreateWorkspaceRequest(
|
public record CreateWorkspaceRequest(
|
||||||
|
Guid OrganizationId,
|
||||||
string Name,
|
string Name,
|
||||||
string Slug,
|
string Slug,
|
||||||
string TimeZone);
|
string TimeZone);
|
||||||
@@ -16,6 +17,7 @@ public class CreateWorkspaceRequestValidator
|
|||||||
{
|
{
|
||||||
public CreateWorkspaceRequestValidator()
|
public CreateWorkspaceRequestValidator()
|
||||||
{
|
{
|
||||||
|
RuleFor(x => x.OrganizationId).NotEmpty();
|
||||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||||
RuleFor(x => x.Slug)
|
RuleFor(x => x.Slug)
|
||||||
.NotEmpty()
|
.NotEmpty()
|
||||||
@@ -38,12 +40,21 @@ public class CreateWorkspaceHandler(
|
|||||||
|
|
||||||
public override async Task HandleAsync(CreateWorkspaceRequest request, CancellationToken ct)
|
public override async Task HandleAsync(CreateWorkspaceRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!accessScopeService.IsManager(User))
|
if (!await accessScopeService.CanCreateWorkspaceAsync(User, request.OrganizationId, ct))
|
||||||
{
|
{
|
||||||
await SendForbiddenAsync(ct);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
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 normalizedName = request.Name.Trim();
|
||||||
string normalizedSlug = request.Slug.Trim().ToLowerInvariant();
|
string normalizedSlug = request.Slug.Trim().ToLowerInvariant();
|
||||||
string normalizedTimeZone = request.TimeZone.Trim();
|
string normalizedTimeZone = request.TimeZone.Trim();
|
||||||
@@ -61,6 +72,7 @@ public class CreateWorkspaceHandler(
|
|||||||
Workspace workspace = new()
|
Workspace workspace = new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
|
OrganizationId = request.OrganizationId,
|
||||||
Name = normalizedName,
|
Name = normalizedName,
|
||||||
Slug = normalizedSlug,
|
Slug = normalizedSlug,
|
||||||
OwnerUserId = User.GetUserId(),
|
OwnerUserId = User.GetUserId(),
|
||||||
@@ -71,18 +83,7 @@ public class CreateWorkspaceHandler(
|
|||||||
dbContext.Workspaces.Add(workspace);
|
dbContext.Workspaces.Add(workspace);
|
||||||
await dbContext.SaveChangesAsync(ct);
|
await dbContext.SaveChangesAsync(ct);
|
||||||
|
|
||||||
WorkspaceDto dto = new(
|
WorkspaceDto dto = WorkspaceDto.FromWorkspace(workspace, []);
|
||||||
workspace.Id,
|
|
||||||
workspace.Name,
|
|
||||||
workspace.Slug,
|
|
||||||
workspace.LogoUrl,
|
|
||||||
workspace.TimeZone,
|
|
||||||
workspace.ApprovalMode,
|
|
||||||
workspace.SchedulePostsAutomaticallyOnApproval,
|
|
||||||
workspace.LockContentAfterApproval,
|
|
||||||
workspace.SendAutomaticApprovalReminders,
|
|
||||||
[],
|
|
||||||
workspace.CreatedAt);
|
|
||||||
|
|
||||||
await SendAsync(dto, StatusCodes.Status201Created, ct);
|
await SendAsync(dto, StatusCodes.Status201Created, ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ public class CreateWorkspaceInviteHandler(
|
|||||||
{
|
{
|
||||||
Guid workspaceId = Route<Guid>("workspaceId");
|
Guid workspaceId = Route<Guid>("workspaceId");
|
||||||
|
|
||||||
if (!accessScopeService.CanManageWorkspace(User, workspaceId))
|
if (!await accessScopeService.CanManageWorkspaceAsync(User, workspaceId, ct))
|
||||||
{
|
{
|
||||||
await SendForbiddenAsync(ct);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ public class GetWorkspaceInvitesHandler(
|
|||||||
{
|
{
|
||||||
Guid workspaceId = Route<Guid>("workspaceId");
|
Guid workspaceId = Route<Guid>("workspaceId");
|
||||||
|
|
||||||
if (!accessScopeService.CanManageWorkspace(User, workspaceId))
|
if (!await accessScopeService.CanManageWorkspaceAsync(User, workspaceId, ct))
|
||||||
{
|
{
|
||||||
await SendForbiddenAsync(ct);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Security.Claims;
|
|||||||
using Socialize.Api.Data;
|
using Socialize.Api.Data;
|
||||||
using Socialize.Api.Modules.Identity.Data;
|
using Socialize.Api.Modules.Identity.Data;
|
||||||
using Socialize.Api.Infrastructure.Security;
|
using Socialize.Api.Infrastructure.Security;
|
||||||
|
using Socialize.Api.Modules.Workspaces.Data;
|
||||||
|
|
||||||
namespace Socialize.Api.Modules.Workspaces.Handlers;
|
namespace Socialize.Api.Modules.Workspaces.Handlers;
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ public record WorkspaceMemberDto(
|
|||||||
string DisplayName,
|
string DisplayName,
|
||||||
string Email,
|
string Email,
|
||||||
string? PortraitUrl,
|
string? PortraitUrl,
|
||||||
|
string RelationshipCategory,
|
||||||
IReadOnlyCollection<string> Roles);
|
IReadOnlyCollection<string> Roles);
|
||||||
|
|
||||||
public class GetWorkspaceMembersHandler(
|
public class GetWorkspaceMembersHandler(
|
||||||
@@ -29,12 +31,20 @@ public class GetWorkspaceMembersHandler(
|
|||||||
{
|
{
|
||||||
Guid workspaceId = Route<Guid>("workspaceId");
|
Guid workspaceId = Route<Guid>("workspaceId");
|
||||||
|
|
||||||
if (!accessScopeService.CanManageWorkspace(User, workspaceId))
|
if (!await accessScopeService.CanManageWorkspaceAsync(User, workspaceId, ct))
|
||||||
{
|
{
|
||||||
await SendForbiddenAsync(ct);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Workspace? workspace = await dbContext.Workspaces
|
||||||
|
.SingleOrDefaultAsync(candidate => candidate.Id == workspaceId, ct);
|
||||||
|
if (workspace is null)
|
||||||
|
{
|
||||||
|
await SendNotFoundAsync(ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
string workspaceClaimValue = workspaceId.ToString();
|
string workspaceClaimValue = workspaceId.ToString();
|
||||||
|
|
||||||
var users = await dbContext.Users
|
var users = await dbContext.Users
|
||||||
@@ -42,7 +52,11 @@ public class GetWorkspaceMembersHandler(
|
|||||||
dbContext.UserClaims.Any(claim =>
|
dbContext.UserClaims.Any(claim =>
|
||||||
claim.UserId == candidate.Id &&
|
claim.UserId == candidate.Id &&
|
||||||
claim.ClaimType == KnownClaims.WorkspaceScope &&
|
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)
|
.OrderBy(candidate => candidate.Lastname)
|
||||||
.ThenBy(candidate => candidate.Firstname)
|
.ThenBy(candidate => candidate.Firstname)
|
||||||
.ThenBy(candidate => candidate.Email)
|
.ThenBy(candidate => candidate.Email)
|
||||||
@@ -70,12 +84,19 @@ public class GetWorkspaceMembersHandler(
|
|||||||
.ToArray(),
|
.ToArray(),
|
||||||
ct);
|
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
|
var members = users
|
||||||
.Select(candidate => new WorkspaceMemberDto(
|
.Select(candidate => new WorkspaceMemberDto(
|
||||||
candidate.Id,
|
candidate.Id,
|
||||||
BuildDisplayName(candidate),
|
BuildDisplayName(candidate),
|
||||||
candidate.Email ?? string.Empty,
|
candidate.Email ?? string.Empty,
|
||||||
candidate.PortraitUrl,
|
candidate.PortraitUrl,
|
||||||
|
organizationMemberUserIds.Contains(candidate.Id) ? "Organization Member" : "External Collaborator",
|
||||||
rolesByUserId.GetValueOrDefault(candidate.Id) ?? Array.Empty<string>()))
|
rolesByUserId.GetValueOrDefault(candidate.Id) ?? Array.Empty<string>()))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public record ApprovalStepConfigurationDto(
|
|||||||
|
|
||||||
public record WorkspaceDto(
|
public record WorkspaceDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
|
Guid OrganizationId,
|
||||||
string Name,
|
string Name,
|
||||||
string Slug,
|
string Slug,
|
||||||
string? LogoUrl,
|
string? LogoUrl,
|
||||||
@@ -28,7 +29,27 @@ public record WorkspaceDto(
|
|||||||
bool LockContentAfterApproval,
|
bool LockContentAfterApproval,
|
||||||
bool SendAutomaticApprovalReminders,
|
bool SendAutomaticApprovalReminders,
|
||||||
IReadOnlyCollection<ApprovalStepConfigurationDto> ApprovalSteps,
|
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(
|
internal class GetWorkspacesHandler(
|
||||||
AppDbContext dbContext,
|
AppDbContext dbContext,
|
||||||
@@ -43,13 +64,9 @@ internal class GetWorkspacesHandler(
|
|||||||
|
|
||||||
public override async Task HandleAsync(CancellationToken ct)
|
public override async Task HandleAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var query = dbContext.Workspaces.AsQueryable();
|
IReadOnlyCollection<Guid> accessibleWorkspaceIds = await accessScopeService.GetAccessibleWorkspaceIdsAsync(User, ct);
|
||||||
|
var query = dbContext.Workspaces
|
||||||
if (!accessScopeService.IsManager(User))
|
.Where(workspace => accessibleWorkspaceIds.Contains(workspace.Id));
|
||||||
{
|
|
||||||
var workspaceScopeIds = User.GetWorkspaceScopeIds();
|
|
||||||
query = query.Where(workspace => workspaceScopeIds.Contains(workspace.Id));
|
|
||||||
}
|
|
||||||
|
|
||||||
var workspaceRows = await query
|
var workspaceRows = await query
|
||||||
.OrderBy(workspace => workspace.Name)
|
.OrderBy(workspace => workspace.Name)
|
||||||
@@ -71,18 +88,9 @@ internal class GetWorkspacesHandler(
|
|||||||
.ToArray());
|
.ToArray());
|
||||||
|
|
||||||
var workspaces = workspaceRows
|
var workspaces = workspaceRows
|
||||||
.Select(workspace => new WorkspaceDto(
|
.Select(workspace => WorkspaceDto.FromWorkspace(
|
||||||
workspace.Id,
|
workspace,
|
||||||
workspace.Name,
|
approvalStepsByWorkspaceId.GetValueOrDefault(workspace.Id) ?? Array.Empty<ApprovalStepConfigurationDto>()))
|
||||||
workspace.Slug,
|
|
||||||
workspace.LogoUrl,
|
|
||||||
workspace.TimeZone,
|
|
||||||
workspace.ApprovalMode,
|
|
||||||
workspace.SchedulePostsAutomaticallyOnApproval,
|
|
||||||
workspace.LockContentAfterApproval,
|
|
||||||
workspace.SendAutomaticApprovalReminders,
|
|
||||||
approvalStepsByWorkspaceId.GetValueOrDefault(workspace.Id) ?? Array.Empty<ApprovalStepConfigurationDto>(),
|
|
||||||
workspace.CreatedAt))
|
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
await SendOkAsync(workspaces, ct);
|
await SendOkAsync(workspaces, ct);
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ public class UpdateWorkspaceHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!accessScopeService.CanManageWorkspace(User, workspace.Id))
|
if (!await accessScopeService.CanManageWorkspaceAsync(User, workspace.Id, ct))
|
||||||
{
|
{
|
||||||
await SendForbiddenAsync(ct);
|
await SendForbiddenAsync(ct);
|
||||||
return;
|
return;
|
||||||
@@ -154,18 +154,7 @@ public class UpdateWorkspaceHandler(
|
|||||||
step.CreatedAt))
|
step.CreatedAt))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
WorkspaceDto dto = new(
|
WorkspaceDto dto = WorkspaceDto.FromWorkspace(workspace, approvalSteps);
|
||||||
workspace.Id,
|
|
||||||
workspace.Name,
|
|
||||||
workspace.Slug,
|
|
||||||
workspace.LogoUrl,
|
|
||||||
workspace.TimeZone,
|
|
||||||
workspace.ApprovalMode,
|
|
||||||
workspace.SchedulePostsAutomaticallyOnApproval,
|
|
||||||
workspace.LockContentAfterApproval,
|
|
||||||
workspace.SendAutomaticApprovalReminders,
|
|
||||||
approvalSteps,
|
|
||||||
workspace.CreatedAt);
|
|
||||||
|
|
||||||
await SendOkAsync(dto, ct);
|
await SendOkAsync(dto, ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ using Socialize.Api.Modules.Feedback;
|
|||||||
using Socialize.Api.Modules.Identity;
|
using Socialize.Api.Modules.Identity;
|
||||||
using Socialize.Api.Modules.Notifications;
|
using Socialize.Api.Modules.Notifications;
|
||||||
using Socialize.Api.Modules.Campaigns;
|
using Socialize.Api.Modules.Campaigns;
|
||||||
|
using Socialize.Api.Modules.Organizations;
|
||||||
using Socialize.Api.Modules.Workspaces;
|
using Socialize.Api.Modules.Workspaces;
|
||||||
|
|
||||||
|
|
||||||
@@ -62,6 +63,7 @@ var postgresConnectionString = builder.Configuration.GetConnectionString("Postgr
|
|||||||
builder.Services.AddAppData(postgresConnectionString);
|
builder.Services.AddAppData(postgresConnectionString);
|
||||||
builder.AddInfrastructureModule();
|
builder.AddInfrastructureModule();
|
||||||
builder.AddIdentityModule();
|
builder.AddIdentityModule();
|
||||||
|
builder.AddOrganizationsModule();
|
||||||
builder.AddWorkspaceModule();
|
builder.AddWorkspaceModule();
|
||||||
builder.AddClientsModule();
|
builder.AddClientsModule();
|
||||||
builder.AddCampaignsModule();
|
builder.AddCampaignsModule();
|
||||||
|
|||||||
@@ -7,18 +7,6 @@ namespace Socialize.Tests.Approvals;
|
|||||||
|
|
||||||
public class ApprovalWorkflowRulesTests
|
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]
|
[Theory]
|
||||||
[InlineData(ApprovalModes.Required, true)]
|
[InlineData(ApprovalModes.Required, true)]
|
||||||
[InlineData(ApprovalModes.MultiLevel, true)]
|
[InlineData(ApprovalModes.MultiLevel, true)]
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
handle {
|
handle {
|
||||||
try_files {path} /index.html
|
try_files {path} {path}/index.html /index.html
|
||||||
file_server
|
file_server
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ Composition registers:
|
|||||||
|
|
||||||
- web services and auth in `DependencyInjection.cs`
|
- web services and auth in `DependencyInjection.cs`
|
||||||
- infrastructure in `Infrastructure/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
|
## 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.
|
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
|
## Frontend
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
|
|||||||
221
docs/FEATURES/organizations.md
Normal file
221
docs/FEATURES/organizations.md
Normal 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.
|
||||||
@@ -18,7 +18,9 @@ Allow workspace managers to invite teammates, clients, and providers into a work
|
|||||||
|
|
||||||
## Domain Rules
|
## Domain Rules
|
||||||
|
|
||||||
|
- Workspace invites grant access to one workspace owned by one organization.
|
||||||
- Workspace invites belong to exactly one workspace.
|
- Workspace invites belong to exactly one workspace.
|
||||||
|
- Invite acceptance does not automatically create organization membership.
|
||||||
- Invite email matching should use normalized email addresses.
|
- Invite email matching should use normalized email addresses.
|
||||||
- Pending invite tokens must be single-use and should expire.
|
- 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.
|
- 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 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.
|
- 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.
|
- 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
|
## Proposed Statuses
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Socialize is a workflow application for social media content review, revision, approval, and publication readiness.
|
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
|
## Primary Users
|
||||||
|
|
||||||
@@ -16,8 +16,12 @@ It is not a public social network. It is a workspace-based coordination tool for
|
|||||||
|
|
||||||
## Core Product Shape
|
## Core Product Shape
|
||||||
|
|
||||||
- workspace-based account boundary
|
- organizations as the SaaS account, billing, subscription, and connector boundary
|
||||||
- agencies able to manage multiple workspaces
|
- 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
|
- content items as the main reviewable unit
|
||||||
- assets and revisions tracked against content items
|
- assets and revisions tracked against content items
|
||||||
- comments and approvals attached to the work itself
|
- 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 direct publishing engine
|
||||||
- full DAM platform
|
- full DAM platform
|
||||||
- analytics suite
|
- analytics suite
|
||||||
- billing or subscription flows
|
- full billing provider integration and pricing/package automation
|
||||||
|
|
||||||
## Current Sources
|
## Current Sources
|
||||||
|
|
||||||
- `docs/product/vision.md`
|
- `docs/product/vision.md`
|
||||||
- `docs/product/glossary.md`
|
- `docs/product/glossary.md`
|
||||||
- `docs/constraints.md`
|
- `docs/constraints.md`
|
||||||
|
- `docs/FEATURES/organizations.md`
|
||||||
|
|||||||
@@ -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/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.
|
- [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.
|
- [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.
|
- [BACKLOG.md](/home/jbourdon/repos/social-media/docs/BACKLOG.md): deferred technical and product work.
|
||||||
|
|
||||||
## Archived
|
## Archived
|
||||||
|
|||||||
132
docs/SEO.md
Normal file
132
docs/SEO.md
Normal 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.
|
||||||
17
docs/TASKS/app-shell/001-responsive-sidebar-default.md
Normal file
17
docs/TASKS/app-shell/001-responsive-sidebar-default.md
Normal 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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
```
|
||||||
65
docs/TASKS/organizations/003-organization-settings-ui.md
Normal file
65
docs/TASKS/organizations/003-organization-settings-ui.md
Normal 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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -11,9 +11,14 @@ These are cross-cutting rules for the current product and codebase. They are int
|
|||||||
|
|
||||||
## Domain Constraints
|
## Domain Constraints
|
||||||
|
|
||||||
- `Workspace` is the top-level scoping boundary.
|
- `Organization` is the SaaS account, billing, subscription, connector, and workspace ownership boundary.
|
||||||
- An agency may manage multiple workspaces.
|
- `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.
|
- `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.
|
- Comments, approvals, assets, and notifications must remain traceable to the underlying workflow entity they relate to.
|
||||||
|
|
||||||
## Backend Constraints
|
## Backend Constraints
|
||||||
|
|||||||
@@ -2,28 +2,67 @@
|
|||||||
|
|
||||||
Use these terms consistently in product docs, specs, UI copy, and code discussions.
|
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:
|
Use:
|
||||||
|
|
||||||
- ownership boundary
|
- billing and subscription boundary
|
||||||
- membership boundary
|
- ownership boundary for workspaces
|
||||||
- scoping boundary for content items, assets, comments, approvals, and notifications
|
- connector and integration configuration boundary
|
||||||
|
- organization-level membership and permission boundary
|
||||||
|
|
||||||
Do not use as:
|
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 internal role
|
||||||
- synonym for team member 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
|
## 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
|
## Client
|
||||||
|
|
||||||
@@ -106,6 +145,8 @@ Examples:
|
|||||||
|
|
||||||
Specific destination, account, handle, page, or feed within a network.
|
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:
|
Examples:
|
||||||
|
|
||||||
- `@MyBrand` on Instagram
|
- `@MyBrand` on Instagram
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Active
|
|||||||
|
|
||||||
`Socialize` is a workflow application for social media content review, revision, approval, and publication readiness.
|
`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
|
## Problem
|
||||||
|
|
||||||
@@ -38,8 +38,11 @@ Provide one system of workflow for drafting, revising, reviewing, approving, and
|
|||||||
|
|
||||||
## Core Product Shape
|
## Core Product Shape
|
||||||
|
|
||||||
- workspace-based account boundary
|
- organizations as the SaaS account, billing, subscription, and connector boundary
|
||||||
- agencies able to manage multiple workspaces
|
- 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
|
- content items as the main reviewable unit
|
||||||
- assets and revisions tracked against content items
|
- assets and revisions tracked against content items
|
||||||
- comments and approvals attached to the work itself
|
- 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 publishing engine in version 1
|
||||||
- not a full DAM platform in version 1
|
- not a full DAM platform in version 1
|
||||||
- not a full analytics product 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
|
## MVP Scope
|
||||||
|
|
||||||
@@ -86,7 +89,7 @@ Version 1 should focus on approval workflow rather than direct publishing.
|
|||||||
- direct social publishing
|
- direct social publishing
|
||||||
- advanced third-party synchronization
|
- advanced third-party synchronization
|
||||||
- analytics suite
|
- analytics suite
|
||||||
- customer billing flows
|
- full customer billing provider integration and pricing/package automation
|
||||||
|
|
||||||
## Current Strategic Assumptions
|
## Current Strategic Assumptions
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"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",
|
"preview": "vite preview",
|
||||||
"api:schema": "node scripts/fetch-openapi.mjs",
|
"api:schema": "node scripts/fetch-openapi.mjs",
|
||||||
"api:types": "openapi-typescript ../shared/openapi/openapi.json -o src/api/schema.d.ts",
|
"api:types": "openapi-typescript ../shared/openapi/openapi.json -o src/api/schema.d.ts",
|
||||||
|
|||||||
34
frontend/scripts/prerender-public.mjs
Normal file
34
frontend/scripts/prerender-public.mjs
Normal 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}`);
|
||||||
|
}
|
||||||
61
frontend/scripts/write-public-seo.mjs
Normal file
61
frontend/scripts/write-public-seo.mjs
Normal 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}`);
|
||||||
@@ -1,28 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-app>
|
<v-app>
|
||||||
<div class="shell-container">
|
<div class="shell-container">
|
||||||
<app-bar
|
<template v-if="showsAppSidebar">
|
||||||
:show-brand="true"
|
<div class="shell-sidebar-wrap">
|
||||||
:collapse-brand="showsAppSidebar && !isSidebarExpanded"
|
<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
|
<div
|
||||||
class="shell-main"
|
class="shell-main"
|
||||||
:class="{ 'shell-main-app': showsAppSidebar }"
|
:class="{ 'shell-main-app': showsAppSidebar }"
|
||||||
>
|
>
|
||||||
<template v-if="showsAppSidebar">
|
<app-bar 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-view">
|
<div class="shell-view">
|
||||||
<router-view></router-view>
|
<router-view></router-view>
|
||||||
@@ -37,6 +34,7 @@
|
|||||||
<script async setup>
|
<script async setup>
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useDisplay } from 'vuetify';
|
||||||
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
||||||
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
||||||
import AppBar from '@/layouts/main/AppBar.vue';
|
import AppBar from '@/layouts/main/AppBar.vue';
|
||||||
@@ -45,7 +43,9 @@
|
|||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const isSidebarExpanded = ref(true);
|
const { smAndDown } = useDisplay();
|
||||||
|
const defaultSidebarExpanded = computed(() => !smAndDown.value);
|
||||||
|
const isSidebarExpanded = ref(defaultSidebarExpanded.value);
|
||||||
|
|
||||||
const showsAppSidebar = computed(() =>
|
const showsAppSidebar = computed(() =>
|
||||||
authStore.isAuthenticated && route.path.startsWith('/app')
|
authStore.isAuthenticated && route.path.startsWith('/app')
|
||||||
@@ -57,13 +57,13 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isSidebarExpanded.value = true;
|
isSidebarExpanded.value = defaultSidebarExpanded.value;
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.shell-container {
|
.shell-container {
|
||||||
@apply min-h-screen flex flex-col;
|
@apply min-h-screen flex flex-row;
|
||||||
@apply w-full font-sans;
|
@apply w-full font-sans;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top left, rgba(255, 174, 94, 0.18), transparent 28%),
|
radial-gradient(circle at top left, rgba(255, 174, 94, 0.18), transparent 28%),
|
||||||
@@ -73,19 +73,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.shell-main {
|
.shell-main {
|
||||||
@apply relative flex flex-1 flex-col;
|
@apply relative flex min-w-0 flex-1 flex-col;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell-main-app {
|
.shell-main-app {
|
||||||
@apply md:flex-row md:items-start;
|
@apply min-h-screen;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell-sidebar-wrap {
|
.shell-sidebar-wrap {
|
||||||
@apply relative flex-shrink-0;
|
@apply sticky top-0 z-30 h-screen flex-shrink-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-boundary-toggle {
|
.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);
|
background: rgba(255, 250, 242, 0.98);
|
||||||
border-color: rgba(23, 32, 51, 0.12);
|
border-color: rgba(23, 32, 51, 0.12);
|
||||||
color: #44516a;
|
color: #44516a;
|
||||||
|
|||||||
171
frontend/src/api/schema.d.ts
vendored
171
frontend/src/api/schema.d.ts
vendored
@@ -100,6 +100,38 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/notifications": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -829,7 +861,7 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
get: operations["SocializeApiModulesApprovalsHandlersGetApprovalsHandler"];
|
get: operations["SocializeApiModulesApprovalsHandlersGetApprovalsHandler"];
|
||||||
put?: never;
|
put?: never;
|
||||||
post: operations["SocializeApiModulesApprovalsHandlersCreateApprovalRequestHandler"];
|
post?: never;
|
||||||
delete?: never;
|
delete?: never;
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
@@ -878,6 +910,8 @@ export interface components {
|
|||||||
SocializeApiModulesWorkspacesHandlersWorkspaceDto: {
|
SocializeApiModulesWorkspacesHandlersWorkspaceDto: {
|
||||||
/** Format: guid */
|
/** Format: guid */
|
||||||
id?: string;
|
id?: string;
|
||||||
|
/** Format: guid */
|
||||||
|
organizationId?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
logoUrl?: string | null;
|
logoUrl?: string | null;
|
||||||
@@ -906,6 +940,8 @@ export interface components {
|
|||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
};
|
};
|
||||||
SocializeApiModulesWorkspacesHandlersCreateWorkspaceRequest: {
|
SocializeApiModulesWorkspacesHandlersCreateWorkspaceRequest: {
|
||||||
|
/** Format: guid */
|
||||||
|
organizationId: string;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
timeZone: string;
|
timeZone: string;
|
||||||
@@ -932,6 +968,7 @@ export interface components {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
portraitUrl?: string | null;
|
portraitUrl?: string | null;
|
||||||
|
relationshipCategory?: string;
|
||||||
roles?: string[];
|
roles?: string[];
|
||||||
};
|
};
|
||||||
SocializeApiModulesWorkspacesHandlersUpdateWorkspaceRequest: {
|
SocializeApiModulesWorkspacesHandlersUpdateWorkspaceRequest: {
|
||||||
@@ -952,6 +989,29 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
requiredApproverCount?: number;
|
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: {
|
SocializeApiModulesNotificationsHandlersNotificationEventDto: {
|
||||||
/** Format: guid */
|
/** Format: guid */
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -1479,22 +1539,9 @@ export interface components {
|
|||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt?: string;
|
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>;
|
SocializeApiModulesApprovalsHandlersGetApprovalsRequest: Record<string, never>;
|
||||||
SocializeApiModulesApprovalsHandlersSubmitApprovalDecisionRequest: {
|
SocializeApiModulesApprovalsHandlersSubmitApprovalDecisionRequest: {
|
||||||
decision: string;
|
decision: string;
|
||||||
comment?: string | null;
|
|
||||||
reviewerName?: string | null;
|
reviewerName?: string | null;
|
||||||
/** Format: email */
|
/** Format: email */
|
||||||
reviewerEmail?: string | null;
|
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: {
|
SocializeApiModulesNotificationsHandlersGetNotificationsHandler: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
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: {
|
SocializeApiModulesApprovalsHandlersSubmitApprovalDecisionHandler: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
59
frontend/src/entry-public-ssr.js
Normal file
59
frontend/src/entry-public-ssr.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -45,23 +45,32 @@ export function useFacebookLogin() {
|
|||||||
const loginWithFacebook = () => {
|
const loginWithFacebook = () => {
|
||||||
if (!isSdkLoaded.value) {
|
if (!isSdkLoaded.value) {
|
||||||
console.error("Facebook SDK non encore chargé !");
|
console.error("Facebook SDK non encore chargé !");
|
||||||
return;
|
return Promise.reject(new Error("Facebook SDK is not loaded"));
|
||||||
}
|
}
|
||||||
|
|
||||||
window.FB.login(
|
return new Promise((resolve, reject) => {
|
||||||
(response) => {
|
window.FB.login(
|
||||||
if (response.authResponse) {
|
async (response) => {
|
||||||
console.log("Utilisateur connecté :", response);
|
if (!response.authResponse) {
|
||||||
const authStore = useAuthStore();
|
console.log("Connexion annulée ou échouée.");
|
||||||
authStore.loginWithFacebook(response.authResponse);
|
reject(new Error("Facebook login was cancelled or failed"));
|
||||||
} else {
|
return;
|
||||||
console.log("Connexion annulée ou échouée.");
|
}
|
||||||
|
|
||||||
|
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(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -1,94 +1,116 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex min-h-full w-full items-center justify-center p-4">
|
<div class="login-page">
|
||||||
<div class="flex w-full max-w-[512px] flex-col gap-10">
|
<div class="login-wrap">
|
||||||
<h1 class="login-text text-center text-2xl font-bold">
|
<router-link
|
||||||
{{ t('title') }}
|
class="login-brand"
|
||||||
</h1>
|
to="/"
|
||||||
|
>
|
||||||
|
<span class="login-brand-mark">S</span>
|
||||||
|
<span class="login-brand-text">Socialize</span>
|
||||||
|
</router-link>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<section class="login-card">
|
||||||
<google-login
|
<h1 class="login-text text-center text-2xl font-bold">
|
||||||
:callback="googleCallback"
|
{{ t('title') }}
|
||||||
popup-type="TOKEN"
|
</h1>
|
||||||
>
|
|
||||||
<button class="secondary">
|
<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
|
<v-icon
|
||||||
:icon="mdiGoogle"
|
:icon="mdiFacebook"
|
||||||
class="mr-2"
|
class="mr-2"
|
||||||
/>
|
/>
|
||||||
{{ t('continueWithGoogle') }}
|
{{ t('continueWithFacebook') }}
|
||||||
</button>
|
</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Error notification -->
|
<!-- Error notification -->
|
||||||
@@ -105,13 +127,15 @@
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { GoogleLogin } from 'vue3-google-login';
|
import { GoogleLogin } from 'vue3-google-login';
|
||||||
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
||||||
|
import { useFacebookLogin } from '@/features/auth/composables/useFacebookLogin.js';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { mdiEye, mdiEyeOff, mdiGoogle } from '@mdi/js';
|
import { mdiEye, mdiEyeOff, mdiFacebook, mdiGoogle } from '@mdi/js';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
const { loginWithFacebook } = useFacebookLogin();
|
||||||
|
|
||||||
const email = ref('');
|
const email = ref('');
|
||||||
const password = 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() {
|
function forgotPassword() {
|
||||||
router.push('/forgot-password');
|
router.push('/forgot-password');
|
||||||
}
|
}
|
||||||
@@ -159,6 +197,41 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.visibility-toggle {
|
||||||
@apply cursor-pointer;
|
@apply cursor-pointer;
|
||||||
@apply transition-opacity duration-300;
|
@apply transition-opacity duration-300;
|
||||||
@@ -194,7 +267,8 @@
|
|||||||
"noAccount": "Don't have an account?",
|
"noAccount": "Don't have an account?",
|
||||||
"register": "Register",
|
"register": "Register",
|
||||||
"loginFailed": "Login failed. Please check your credentials.",
|
"loginFailed": "Login failed. Please check your credentials.",
|
||||||
"continueWithGoogle": "Continue with Google"
|
"continueWithGoogle": "Continue with Google",
|
||||||
|
"continueWithFacebook": "Continue with Facebook"
|
||||||
},
|
},
|
||||||
"fr": {
|
"fr": {
|
||||||
"title": "Se connecter",
|
"title": "Se connecter",
|
||||||
@@ -208,7 +282,8 @@
|
|||||||
"noAccount": "Vous n'avez pas de compte?",
|
"noAccount": "Vous n'avez pas de compte?",
|
||||||
"register": "S'inscrire",
|
"register": "S'inscrire",
|
||||||
"loginFailed": "Échec de la connexion. Veuillez vérifier vos identifiants.",
|
"loginFailed": "Échec de la connexion. Veuillez vérifier vos identifiants.",
|
||||||
"continueWithGoogle": "Continuer avec Google"
|
"continueWithGoogle": "Continuer avec Google",
|
||||||
|
"continueWithFacebook": "Continuer avec Facebook"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</i18n>
|
</i18n>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -20,7 +20,6 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
|||||||
asset: false,
|
asset: false,
|
||||||
assetRevision: false,
|
assetRevision: false,
|
||||||
comment: false,
|
comment: false,
|
||||||
approval: false,
|
|
||||||
decision: false,
|
decision: false,
|
||||||
status: 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) {
|
async function submitDecision(contentItemId, approvalId, payload) {
|
||||||
actions.decision = true;
|
actions.decision = true;
|
||||||
|
|
||||||
@@ -248,7 +227,6 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
|
|||||||
addAssetRevision,
|
addAssetRevision,
|
||||||
addComment,
|
addComment,
|
||||||
resolveComment,
|
resolveComment,
|
||||||
createApproval,
|
|
||||||
submitDecision,
|
submitDecision,
|
||||||
updateStatus,
|
updateStatus,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onBeforeUnmount, onMounted, reactive, watch } from 'vue';
|
import { computed, onBeforeUnmount, reactive, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useSessionStorage } from '@vueuse/core';
|
import { useSessionStorage } from '@vueuse/core';
|
||||||
import AppAvatar from '@/components/AppAvatar.vue';
|
import AppAvatar from '@/components/AppAvatar.vue';
|
||||||
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
|
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
|
||||||
import { useClientsStore } from '@/features/clients/stores/clientsStore.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 { useContentItemDetailStore } from '@/features/content/stores/contentItemDetailStore.js';
|
||||||
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
|
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
|
||||||
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
|
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
|
||||||
@@ -33,26 +34,10 @@
|
|||||||
placements: [],
|
placements: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const approvalForm = reactive({
|
|
||||||
stage: 'Internal',
|
|
||||||
reviewerName: '',
|
|
||||||
reviewerEmail: '',
|
|
||||||
dueAt: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const commentForm = reactive({
|
const commentForm = reactive({
|
||||||
body: '',
|
body: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const decisionForms = reactive({});
|
|
||||||
const manualStatuses = [
|
|
||||||
'Draft',
|
|
||||||
'In production',
|
|
||||||
'In approval',
|
|
||||||
'Approved',
|
|
||||||
'Scheduled',
|
|
||||||
'Published',
|
|
||||||
];
|
|
||||||
const saveError = reactive({
|
const saveError = reactive({
|
||||||
message: '',
|
message: '',
|
||||||
});
|
});
|
||||||
@@ -88,7 +73,7 @@
|
|||||||
new Map(campaignsStore.campaigns.map(campaign => [campaign.id, campaign.name]))
|
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 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) {
|
function blankPlacement(channel = null) {
|
||||||
return {
|
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) {
|
function syncPlacementChannel(placement, value) {
|
||||||
const channel = availableChannels.value.find(candidate => candidate.id === value);
|
const channel = availableChannels.value.find(candidate => candidate.id === value);
|
||||||
placement.channelId = value;
|
placement.channelId = value;
|
||||||
@@ -353,30 +315,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitApproval() {
|
async function submitDecision(approvalId, payload) {
|
||||||
if (!contentItemId.value) {
|
if (!contentItemId.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await detailStore.createApproval(contentItemId.value, {
|
await detailStore.submitDecision(contentItemId.value, approvalId, payload);
|
||||||
...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 = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitComment() {
|
async function submitComment() {
|
||||||
@@ -388,22 +332,10 @@
|
|||||||
commentForm.body = '';
|
commentForm.body = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function moveStatus(status) {
|
|
||||||
if (!contentItemId.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await detailStore.updateStatus(contentItemId.value, status);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(value) {
|
function formatDateTime(value) {
|
||||||
return value ? new Date(value).toLocaleString() : '';
|
return value ? new Date(value).toLocaleString() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(value) {
|
|
||||||
return value ? new Date(value).toLocaleDateString() : 'No due date';
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [
|
() => [
|
||||||
isCreateMode.value,
|
isCreateMode.value,
|
||||||
@@ -434,12 +366,6 @@
|
|||||||
{ deep: true }
|
{ deep: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (!isCreateMode.value) {
|
|
||||||
await hydrateEditor();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
detailStore.reset();
|
detailStore.reset();
|
||||||
});
|
});
|
||||||
@@ -502,165 +428,32 @@
|
|||||||
{{ saveError.message }}
|
{{ saveError.message }}
|
||||||
</div>
|
</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">
|
<div class="editor-grid">
|
||||||
<aside class="panel side-panel">
|
<section class="work-panel">
|
||||||
<div class="panel-heading">
|
<ContentApprovalPanel
|
||||||
<strong>Approval</strong>
|
:approvals="detailStore.approvals"
|
||||||
<span v-if="!isCreateMode">{{ detailStore.approvals.length }} {{ isMultiLevelApproval ? 'steps' : 'requests' }}</span>
|
:approval-mode="approvalMode"
|
||||||
</div>
|
:content-status="item?.status ?? 'Draft'"
|
||||||
|
:is-create-mode="isCreateMode"
|
||||||
|
:is-submitting-decision="detailStore.actions.decision"
|
||||||
|
@submit-decision="submitDecision"
|
||||||
|
/>
|
||||||
|
|
||||||
<div
|
<main class="content-panel">
|
||||||
v-if="isCreateMode"
|
<div class="content-section">
|
||||||
class="empty-note"
|
<div class="section-title-row">
|
||||||
>
|
<strong>Content</strong>
|
||||||
Save the content first to request approvals.
|
<span>{{ placementSummary || 'No channels selected yet' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<div class="form-grid">
|
||||||
<div
|
<label class="field">
|
||||||
v-if="isMultiLevelApproval"
|
<span>Title</span>
|
||||||
class="empty-note"
|
<input
|
||||||
>
|
v-model="form.title"
|
||||||
Move this content to In approval to start the configured workflow steps.
|
type="text"
|
||||||
</div>
|
/>
|
||||||
|
</label>
|
||||||
<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>
|
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Campaign</span>
|
<span>Campaign</span>
|
||||||
@@ -921,8 +714,9 @@
|
|||||||
>
|
>
|
||||||
Add at least one channel to define where this content will be published.
|
Add at least one channel to define where this content will be published.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
</section>
|
||||||
|
|
||||||
<aside class="panel side-panel">
|
<aside class="panel side-panel">
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
@@ -1024,7 +818,6 @@
|
|||||||
|
|
||||||
.editor-header p,
|
.editor-header p,
|
||||||
.panel-heading span,
|
.panel-heading span,
|
||||||
.sub-card span,
|
|
||||||
.timeline-row span,
|
.timeline-row span,
|
||||||
.timeline-row small,
|
.timeline-row small,
|
||||||
.empty-note,
|
.empty-note,
|
||||||
@@ -1035,7 +828,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-actions,
|
.header-actions,
|
||||||
.quick-actions,
|
|
||||||
.status-badges {
|
.status-badges {
|
||||||
@apply flex flex-wrap items-center gap-3;
|
@apply flex flex-wrap items-center gap-3;
|
||||||
}
|
}
|
||||||
@@ -1062,7 +854,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editor-grid {
|
.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 {
|
.panel {
|
||||||
@@ -1076,20 +874,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.content-panel {
|
.content-panel {
|
||||||
@apply gap-6;
|
@apply flex min-h-0 flex-col gap-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-heading,
|
.panel-heading,
|
||||||
.section-title-row,
|
.section-title-row,
|
||||||
.placement-header,
|
.placement-header {
|
||||||
.sub-card-header {
|
|
||||||
@apply flex items-start justify-between gap-3;
|
@apply flex items-start justify-between gap-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-heading strong,
|
.panel-heading strong,
|
||||||
.section-title-row strong,
|
.section-title-row strong,
|
||||||
.placement-header strong,
|
.placement-header strong,
|
||||||
.sub-card strong,
|
|
||||||
.timeline-row strong {
|
.timeline-row strong {
|
||||||
color: #172033;
|
color: #172033;
|
||||||
}
|
}
|
||||||
@@ -1172,7 +968,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.placement-card,
|
.placement-card,
|
||||||
.sub-card,
|
|
||||||
.media-card {
|
.media-card {
|
||||||
@apply rounded-[1.25rem] border p-4;
|
@apply rounded-[1.25rem] border p-4;
|
||||||
background: #fffaf2;
|
background: #fffaf2;
|
||||||
|
|||||||
113
frontend/src/features/landing/components/LandingSiteMenu.vue
Normal file
113
frontend/src/features/landing/components/LandingSiteMenu.vue
Normal 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>
|
||||||
51
frontend/src/features/landing/publicPageMeta.js
Normal file
51
frontend/src/features/landing/publicPageMeta.js
Normal 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),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
59
frontend/src/features/landing/views/BlogsPage.vue
Normal file
59
frontend/src/features/landing/views/BlogsPage.vue
Normal 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>
|
||||||
59
frontend/src/features/landing/views/GuidesPage.vue
Normal file
59
frontend/src/features/landing/views/GuidesPage.vue
Normal 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>
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
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(() => [
|
const pillars = computed(() => [
|
||||||
{
|
{
|
||||||
@@ -25,37 +33,43 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="landing-shell">
|
<div class="landing-page">
|
||||||
<section class="hero-card">
|
<LandingSiteMenu />
|
||||||
<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">
|
<main class="landing-shell">
|
||||||
<div class="hero-panel-title">Version 1 workflow</div>
|
<section
|
||||||
<ol class="workflow-list">
|
id="products"
|
||||||
<li
|
class="hero-card"
|
||||||
v-for="step in workflow"
|
>
|
||||||
:key="step"
|
<div class="hero-copy">
|
||||||
>
|
<div class="eyebrow">Social media approval workflow</div>
|
||||||
{{ step }}
|
<h1>Replace Drive links, scattered comments, and manual follow-up with one review system.</h1>
|
||||||
</li>
|
<p>
|
||||||
</ol>
|
Socialize is being rebuilt as an agency workflow product for content review, revision tracking,
|
||||||
</div>
|
client approval, and publication readiness.
|
||||||
</section>
|
</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">
|
<section class="pillars-grid">
|
||||||
<article
|
<article
|
||||||
@@ -88,10 +102,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.landing-page {
|
||||||
|
@apply min-h-screen w-full;
|
||||||
|
}
|
||||||
|
|
||||||
.landing-shell {
|
.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;
|
@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;
|
@apply mt-2 block text-sm leading-6;
|
||||||
color: #3f4d63;
|
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>
|
</style>
|
||||||
|
|||||||
75
frontend/src/features/landing/views/PricingPage.vue
Normal file
75
frontend/src/features/landing/views/PricingPage.vue
Normal 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>
|
||||||
59
frontend/src/features/landing/views/ProductPage.vue
Normal file
59
frontend/src/features/landing/views/ProductPage.vue
Normal 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>
|
||||||
142
frontend/src/features/organizations/stores/organizationStore.js
Normal file
142
frontend/src/features/organizations/stores/organizationStore.js
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
||||||
|
import { useOrganizationStore } from '@/features/organizations/stores/organizationStore.js';
|
||||||
import { useClient } from '@/plugins/api.js';
|
import { useClient } from '@/plugins/api.js';
|
||||||
|
|
||||||
export const useWorkspaceStore = defineStore('workspace', () => {
|
export const useWorkspaceStore = defineStore('workspace', () => {
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
const organizationStore = useOrganizationStore();
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
|
|
||||||
const workspaces = ref([]);
|
const workspaces = ref([]);
|
||||||
@@ -42,6 +44,8 @@ export const useWorkspaceStore = defineStore('workspace', () => {
|
|||||||
if (!workspaces.value.some(workspace => workspace.id === activeWorkspaceId.value)) {
|
if (!workspaces.value.some(workspace => workspace.id === activeWorkspaceId.value)) {
|
||||||
activeWorkspaceId.value = workspaces.value[0]?.id ?? null;
|
activeWorkspaceId.value = workspaces.value[0]?.id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
organizationStore.setSelectedOrganizationFromWorkspace(activeWorkspace.value);
|
||||||
} catch (fetchError) {
|
} catch (fetchError) {
|
||||||
console.error('Failed to fetch workspaces:', fetchError);
|
console.error('Failed to fetch workspaces:', fetchError);
|
||||||
workspaces.value = [];
|
workspaces.value = [];
|
||||||
@@ -161,8 +165,14 @@ export const useWorkspaceStore = defineStore('workspace', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setActiveWorkspace(workspaceId) {
|
function setActiveWorkspace(workspaceId) {
|
||||||
|
if (!workspaceId) {
|
||||||
|
activeWorkspaceId.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (workspaces.value.some(workspace => workspace.id === workspaceId)) {
|
if (workspaces.value.some(workspace => workspace.id === workspaceId)) {
|
||||||
activeWorkspaceId.value = workspaceId;
|
activeWorkspaceId.value = workspaceId;
|
||||||
|
organizationStore.setSelectedOrganizationFromWorkspace(activeWorkspace.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
import { computed, reactive, ref } from 'vue';
|
import { computed, reactive, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useOrganizationStore } from '@/features/organizations/stores/organizationStore.js';
|
||||||
import TimeZoneSelect from '@/features/workspaces/components/TimeZoneSelect.vue';
|
import TimeZoneSelect from '@/features/workspaces/components/TimeZoneSelect.vue';
|
||||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const organizationStore = useOrganizationStore();
|
||||||
const workspaceStore = useWorkspaceStore();
|
const workspaceStore = useWorkspaceStore();
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
@@ -23,6 +25,10 @@
|
|||||||
|
|
||||||
return slugify(form.name);
|
return slugify(form.name);
|
||||||
});
|
});
|
||||||
|
const selectedOrganizationId = computed({
|
||||||
|
get: () => organizationStore.selectedOrganizationId,
|
||||||
|
set: value => organizationStore.setSelectedOrganization(value),
|
||||||
|
});
|
||||||
|
|
||||||
function computedDefaultTimeZone() {
|
function computedDefaultTimeZone() {
|
||||||
return workspaceStore.activeWorkspace?.timeZone || 'America/Montreal';
|
return workspaceStore.activeWorkspace?.timeZone || 'America/Montreal';
|
||||||
@@ -48,13 +54,14 @@
|
|||||||
const slug = slugify(form.slug || form.name);
|
const slug = slugify(form.slug || form.name);
|
||||||
const timeZone = form.timeZone.trim();
|
const timeZone = form.timeZone.trim();
|
||||||
|
|
||||||
if (!name || !slug || !timeZone) {
|
if (!name || !slug || !timeZone || !selectedOrganizationId.value) {
|
||||||
formError.value = t('workspaceCreate.errors.required');
|
formError.value = t('workspaceCreate.errors.required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await workspaceStore.createWorkspace({
|
await workspaceStore.createWorkspace({
|
||||||
|
organizationId: selectedOrganizationId.value,
|
||||||
name,
|
name,
|
||||||
slug,
|
slug,
|
||||||
timeZone,
|
timeZone,
|
||||||
@@ -114,6 +121,22 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</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">
|
<label class="field">
|
||||||
<span>{{ t('workspaceCreate.fields.slug') }}</span>
|
<span>{{ t('workspaceCreate.fields.slug') }}</span>
|
||||||
<input
|
<input
|
||||||
@@ -242,7 +265,8 @@
|
|||||||
color: #172033;
|
color: #172033;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field input {
|
.field input,
|
||||||
|
.field select {
|
||||||
@apply rounded-[1rem] border px-4 py-3 text-sm;
|
@apply rounded-[1rem] border px-4 py-3 text-sm;
|
||||||
background: #fffdf8;
|
background: #fffdf8;
|
||||||
border-color: rgba(23, 32, 51, 0.1);
|
border-color: rgba(23, 32, 51, 0.1);
|
||||||
|
|||||||
@@ -10,17 +10,6 @@
|
|||||||
mdiPlus,
|
mdiPlus,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
showBrand: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
collapseBrand: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
@@ -73,21 +62,6 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav class="side-container">
|
<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">
|
||||||
<div class="side-menu-items side-menu-left">
|
<div class="side-menu-items side-menu-left">
|
||||||
<WorkspaceSelector
|
<WorkspaceSelector
|
||||||
@@ -123,44 +97,15 @@
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.side-container {
|
.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);
|
background: rgba(255, 250, 242, 0.82);
|
||||||
backdrop-filter: blur(18px);
|
backdrop-filter: blur(18px);
|
||||||
border-bottom: 1px solid rgba(23, 32, 51, 0.08);
|
border-bottom: 1px solid rgba(23, 32, 51, 0.08);
|
||||||
isolation: isolate;
|
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 {
|
.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 {
|
.side-menu-items {
|
||||||
|
|||||||
@@ -2,15 +2,13 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import AppAvatar from '@/components/AppAvatar.vue';
|
|
||||||
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
||||||
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
|
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
|
||||||
import { useLanguageStore } from '@/stores/languageStore.js';
|
|
||||||
import { useNotificationsStore } from '@/features/notifications/stores/notificationsStore.js';
|
import { useNotificationsStore } from '@/features/notifications/stores/notificationsStore.js';
|
||||||
import { getNotificationRoute } from '@/features/notifications/notificationRoutes.js';
|
import { getNotificationRoute } from '@/features/notifications/notificationRoutes.js';
|
||||||
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
|
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
|
||||||
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
|
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
|
||||||
import { useUserProfileStore } from '@/features/user-profile/stores/userProfileStore.js';
|
import SidebarUserMenu from './SidebarUserMenu.vue';
|
||||||
import {
|
import {
|
||||||
mdiBellOutline,
|
mdiBellOutline,
|
||||||
mdiCalendarMonthOutline,
|
mdiCalendarMonthOutline,
|
||||||
@@ -39,16 +37,12 @@
|
|||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const channelsStore = useChannelsStore();
|
const channelsStore = useChannelsStore();
|
||||||
const contentItemsStore = useContentItemsStore();
|
const contentItemsStore = useContentItemsStore();
|
||||||
const languageStore = useLanguageStore();
|
|
||||||
const notificationsStore = useNotificationsStore();
|
const notificationsStore = useNotificationsStore();
|
||||||
const campaignsStore = useCampaignsStore();
|
const campaignsStore = useCampaignsStore();
|
||||||
const userProfileStore = useUserProfileStore();
|
|
||||||
const isUserMenuOpen = ref(false);
|
|
||||||
const isNotificationsOpen = ref(false);
|
const isNotificationsOpen = ref(false);
|
||||||
const isSearchFocused = ref(false);
|
const isSearchFocused = ref(false);
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
|
|
||||||
const userMenuRef = ref(null);
|
|
||||||
const notificationsRef = ref(null);
|
const notificationsRef = ref(null);
|
||||||
const searchRef = ref(null);
|
const searchRef = ref(null);
|
||||||
|
|
||||||
@@ -133,25 +127,6 @@
|
|||||||
isNotificationsOpen.value = !isNotificationsOpen.value;
|
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) {
|
function formatNotificationTitle(notification) {
|
||||||
return notificationTitleMap.value[notification.eventType] ?? notification.message;
|
return notificationTitleMap.value[notification.eventType] ?? notification.message;
|
||||||
}
|
}
|
||||||
@@ -182,11 +157,6 @@
|
|||||||
await router.push(result.route);
|
await router.push(result.route);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLogout() {
|
|
||||||
isUserMenuOpen.value = false;
|
|
||||||
authStore.logout();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDocumentClick(event) {
|
function handleDocumentClick(event) {
|
||||||
if (searchRef.value && !searchRef.value.contains(event.target)) {
|
if (searchRef.value && !searchRef.value.contains(event.target)) {
|
||||||
isSearchFocused.value = false;
|
isSearchFocused.value = false;
|
||||||
@@ -195,10 +165,6 @@
|
|||||||
if (isNotificationsOpen.value && notificationsRef.value && !notificationsRef.value.contains(event.target)) {
|
if (isNotificationsOpen.value && notificationsRef.value && !notificationsRef.value.contains(event.target)) {
|
||||||
isNotificationsOpen.value = false;
|
isNotificationsOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isUserMenuOpen.value && userMenuRef.value && !userMenuRef.value.contains(event.target)) {
|
|
||||||
isUserMenuOpen.value = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -215,15 +181,6 @@
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.isExpanded,
|
|
||||||
isExpanded => {
|
|
||||||
if (!isExpanded) {
|
|
||||||
isUserMenuOpen.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('click', handleDocumentClick);
|
document.addEventListener('click', handleDocumentClick);
|
||||||
});
|
});
|
||||||
@@ -234,11 +191,26 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside
|
<aside
|
||||||
class="app-sidebar"
|
class="app-sidebar"
|
||||||
:class="{ 'app-sidebar-collapsed': !isExpanded }"
|
: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
|
<div
|
||||||
v-if="authStore.isAuthenticated"
|
v-if="authStore.isAuthenticated"
|
||||||
class="sidebar-section sidebar-utilities"
|
class="sidebar-section sidebar-utilities"
|
||||||
@@ -578,75 +550,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
|
<SidebarUserMenu
|
||||||
|
v-if="authStore.isAuthenticated"
|
||||||
|
:is-expanded="isExpanded"
|
||||||
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.app-sidebar {
|
.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);
|
border-right: 1px solid rgba(23, 32, 51, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-sidebar-inner {
|
.app-sidebar-scroll {
|
||||||
@apply flex h-full flex-col gap-4 overflow-y-auto py-3 pr-3;
|
@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 {
|
.sidebar-utilities {
|
||||||
@@ -776,63 +725,6 @@
|
|||||||
color: #172033;
|
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 {
|
.sidebar-section {
|
||||||
@apply flex flex-col gap-2;
|
@apply flex flex-col gap-2;
|
||||||
}
|
}
|
||||||
|
|||||||
187
frontend/src/layouts/main/SidebarUserMenu.vue
Normal file
187
frontend/src/layouts/main/SidebarUserMenu.vue
Normal 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>
|
||||||
@@ -4,26 +4,59 @@
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import AppAvatar from '@/components/AppAvatar.vue';
|
import AppAvatar from '@/components/AppAvatar.vue';
|
||||||
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
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 { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||||
import {
|
import {
|
||||||
mdiChevronDown,
|
mdiChevronDown,
|
||||||
|
mdiCogOutline,
|
||||||
mdiPlus,
|
mdiPlus,
|
||||||
|
mdiSwapHorizontal,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const workspaceStore = useWorkspaceStore();
|
const workspaceStore = useWorkspaceStore();
|
||||||
|
const organizationStore = useOrganizationStore();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
const isWorkspaceMenuOpen = ref(false);
|
const isWorkspaceMenuOpen = ref(false);
|
||||||
|
const isOrganizationListOpen = ref(false);
|
||||||
const workspaceMenuRef = ref(null);
|
const workspaceMenuRef = ref(null);
|
||||||
|
|
||||||
const canSwitchWorkspaces = computed(() => workspaceStore.workspaces.length > 1);
|
const activeOrganization = computed(() => organizationStore.activeOrganization);
|
||||||
const canManageWorkspaces = computed(() => authStore.isManager);
|
const visibleWorkspaces = computed(() => {
|
||||||
const canOpenWorkspaceMenu = computed(() => canSwitchWorkspaces.value || canManageWorkspaces.value);
|
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(() =>
|
const activeWorkspaceName = computed(() =>
|
||||||
workspaceStore.activeWorkspace?.name || t('nav.noWorkspace')
|
workspaceStore.activeWorkspace?.name || t('nav.noWorkspace')
|
||||||
);
|
);
|
||||||
|
const activeOrganizationName = computed(() =>
|
||||||
|
activeOrganization.value?.name || t('workspaceSelector.noOrganization')
|
||||||
|
);
|
||||||
|
|
||||||
function toggleWorkspaceMenu() {
|
function toggleWorkspaceMenu() {
|
||||||
if (!canOpenWorkspaceMenu.value) {
|
if (!canOpenWorkspaceMenu.value) {
|
||||||
@@ -31,21 +64,45 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
isWorkspaceMenuOpen.value = !isWorkspaceMenuOpen.value;
|
isWorkspaceMenuOpen.value = !isWorkspaceMenuOpen.value;
|
||||||
|
isOrganizationListOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function chooseWorkspace(workspaceId) {
|
function chooseWorkspace(workspaceId) {
|
||||||
workspaceStore.setActiveWorkspace(workspaceId);
|
workspaceStore.setActiveWorkspace(workspaceId);
|
||||||
isWorkspaceMenuOpen.value = false;
|
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() {
|
async function openCreateWorkspace() {
|
||||||
isWorkspaceMenuOpen.value = false;
|
isWorkspaceMenuOpen.value = false;
|
||||||
|
isOrganizationListOpen.value = false;
|
||||||
await router.push({ name: 'workspace-create' });
|
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) {
|
function handleDocumentClick(event) {
|
||||||
if (workspaceMenuRef.value && !workspaceMenuRef.value.contains(event.target)) {
|
if (workspaceMenuRef.value && !workspaceMenuRef.value.contains(event.target)) {
|
||||||
isWorkspaceMenuOpen.value = false;
|
isWorkspaceMenuOpen.value = false;
|
||||||
|
isOrganizationListOpen.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +144,7 @@
|
|||||||
class="user-menu"
|
class="user-menu"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
v-for="workspace in workspaceStore.workspaces"
|
v-for="workspace in visibleWorkspaces"
|
||||||
:key="workspace.id"
|
:key="workspace.id"
|
||||||
class="user-menu-item"
|
class="user-menu-item"
|
||||||
:class="{ 'user-menu-item-active': workspace.id === workspaceStore.activeWorkspaceId }"
|
:class="{ 'user-menu-item-active': workspace.id === workspaceStore.activeWorkspaceId }"
|
||||||
@@ -113,6 +170,59 @@
|
|||||||
<span>{{ t('workspaceSelector.createAction') }}</span>
|
<span>{{ t('workspaceSelector.createAction') }}</span>
|
||||||
<v-icon :icon="mdiPlus" />
|
<v-icon :icon="mdiPlus" />
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -160,8 +270,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.user-menu {
|
.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;
|
@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;
|
||||||
background: rgba(255, 255, 255, 0.96);
|
isolation: isolate;
|
||||||
|
background: #fffdf8;
|
||||||
|
background-clip: padding-box;
|
||||||
border-color: rgba(23, 32, 51, 0.08);
|
border-color: rgba(23, 32, 51, 0.08);
|
||||||
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.12);
|
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.12);
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
@@ -199,4 +311,49 @@
|
|||||||
@apply justify-between border border-dashed;
|
@apply justify-between border border-dashed;
|
||||||
border-color: rgba(23, 32, 51, 0.12);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -39,7 +39,10 @@
|
|||||||
"saving": "Saving..."
|
"saving": "Saving..."
|
||||||
},
|
},
|
||||||
"workspaceSelector": {
|
"workspaceSelector": {
|
||||||
"createAction": "Add workspace"
|
"createAction": "Add workspace",
|
||||||
|
"organizationLabel": "Organization",
|
||||||
|
"organizationSettings": "Organization settings",
|
||||||
|
"noOrganization": "No organization"
|
||||||
},
|
},
|
||||||
"workspaceCreate": {
|
"workspaceCreate": {
|
||||||
"eyebrow": "Workspace",
|
"eyebrow": "Workspace",
|
||||||
@@ -54,6 +57,7 @@
|
|||||||
"fields": {
|
"fields": {
|
||||||
"name": "Workspace name",
|
"name": "Workspace name",
|
||||||
"namePlaceholder": "Northwind Studio",
|
"namePlaceholder": "Northwind Studio",
|
||||||
|
"organization": "Organization",
|
||||||
"slug": "Workspace slug",
|
"slug": "Workspace slug",
|
||||||
"slugPlaceholder": "northwind-studio",
|
"slugPlaceholder": "northwind-studio",
|
||||||
"timeZone": "Time zone"
|
"timeZone": "Time zone"
|
||||||
@@ -63,6 +67,64 @@
|
|||||||
"createFailed": "The workspace could not be created."
|
"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": {
|
"nav": {
|
||||||
"brandCaption": "Approval workflow",
|
"brandCaption": "Approval workflow",
|
||||||
"workspace": "Workspace",
|
"workspace": "Workspace",
|
||||||
|
|||||||
@@ -39,7 +39,10 @@
|
|||||||
"saving": "Enregistrement..."
|
"saving": "Enregistrement..."
|
||||||
},
|
},
|
||||||
"workspaceSelector": {
|
"workspaceSelector": {
|
||||||
"createAction": "Ajouter un espace"
|
"createAction": "Ajouter un espace",
|
||||||
|
"organizationLabel": "Organisation",
|
||||||
|
"organizationSettings": "Parametres de l'organisation",
|
||||||
|
"noOrganization": "Aucune organisation"
|
||||||
},
|
},
|
||||||
"workspaceCreate": {
|
"workspaceCreate": {
|
||||||
"eyebrow": "Espace",
|
"eyebrow": "Espace",
|
||||||
@@ -54,6 +57,7 @@
|
|||||||
"fields": {
|
"fields": {
|
||||||
"name": "Nom de l'espace",
|
"name": "Nom de l'espace",
|
||||||
"namePlaceholder": "Northwind Studio",
|
"namePlaceholder": "Northwind Studio",
|
||||||
|
"organization": "Organisation",
|
||||||
"slug": "Slug de l'espace",
|
"slug": "Slug de l'espace",
|
||||||
"slugPlaceholder": "northwind-studio",
|
"slugPlaceholder": "northwind-studio",
|
||||||
"timeZone": "Fuseau horaire"
|
"timeZone": "Fuseau horaire"
|
||||||
@@ -63,6 +67,64 @@
|
|||||||
"createFailed": "L'espace n'a pas pu etre cree."
|
"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": {
|
"nav": {
|
||||||
"brandCaption": "Flux d'approbation",
|
"brandCaption": "Flux d'approbation",
|
||||||
"workspace": "Espace de travail",
|
"workspace": "Espace de travail",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { useNotificationsStore } from '@/features/notifications/stores/notificat
|
|||||||
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
|
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
|
||||||
import { i18n } from '@/plugins/i18n.js';
|
import { i18n } from '@/plugins/i18n.js';
|
||||||
import config from '@/config.js';
|
import config from '@/config.js';
|
||||||
|
import { createHead } from '@vueuse/head';
|
||||||
|
|
||||||
const vuetify = createVuetify({
|
const vuetify = createVuetify({
|
||||||
components: {
|
components: {
|
||||||
@@ -78,9 +79,11 @@ const vuetify = createVuetify({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const pinia = createPinia();
|
const pinia = createPinia();
|
||||||
|
const head = createHead();
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
.use(pinia)
|
.use(pinia)
|
||||||
|
.use(head)
|
||||||
.use(vuetify)
|
.use(vuetify)
|
||||||
.use(router)
|
.use(router)
|
||||||
.use(i18n)
|
.use(i18n)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user