feat: add google drive dam oauth connect
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
namespace Socialize.Api.Modules.Organizations.Configuration;
|
||||
|
||||
internal sealed class GoogleDriveDamOAuthOptions
|
||||
{
|
||||
public const string SectionName = "GoogleDriveDamOAuth";
|
||||
|
||||
public string? ClientId { get; set; }
|
||||
public string? ClientSecret { get; set; }
|
||||
public string? RedirectUri { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Configuration;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Organizations.Data;
|
||||
using Socialize.Api.Modules.Organizations.Services;
|
||||
|
||||
namespace Socialize.Api.Modules.Organizations.Handlers;
|
||||
|
||||
internal record StartGoogleDriveDamOAuthResponse(
|
||||
bool IsConfigured,
|
||||
string? AuthorizationUrl,
|
||||
string? Message);
|
||||
|
||||
internal sealed class StartGoogleDriveDamOAuthHandler(
|
||||
GoogleDriveDamOAuthService oauthService,
|
||||
OrganizationAccessService organizationAccessService)
|
||||
: EndpointWithoutRequest<StartGoogleDriveDamOAuthResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/organizations/{organizationId:guid}/google-drive-dam/oauth/start");
|
||||
Options(o => o.WithTags("Organizations"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
Guid organizationId = Route<Guid>("organizationId");
|
||||
if (!await organizationAccessService.HasOrganizationPermissionAsync(
|
||||
User,
|
||||
organizationId,
|
||||
OrganizationPermissions.ManageConnectors,
|
||||
ct))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!oauthService.IsConfigured)
|
||||
{
|
||||
await SendOkAsync(
|
||||
new StartGoogleDriveDamOAuthResponse(
|
||||
false,
|
||||
null,
|
||||
"Google Drive DAM OAuth is not configured on the server."),
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string authorizationUrl = oauthService.BuildAuthorizationUrl(
|
||||
organizationId,
|
||||
User.GetUserId(),
|
||||
oauthService.GetRedirectUri(HttpContext.Request));
|
||||
await SendOkAsync(new StartGoogleDriveDamOAuthResponse(true, authorizationUrl, null), ct);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class GoogleDriveDamOAuthCallbackHandler(
|
||||
AppDbContext dbContext,
|
||||
GoogleDriveDamOAuthService oauthService,
|
||||
IOptionsSnapshot<WebsiteOptions> websiteOptions)
|
||||
: EndpointWithoutRequest
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/organizations/google-drive-dam/oauth/callback");
|
||||
AllowAnonymous();
|
||||
Options(o => o.WithTags("Organizations"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
string? protectedState = Query<string?>("state");
|
||||
string? code = Query<string?>("code");
|
||||
string? error = Query<string?>("error");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(error))
|
||||
{
|
||||
await RedirectToOrganizationSettingsAsync(null, $"googleDriveDam=error&reason={Uri.EscapeDataString(error)}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(protectedState) || string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
await RedirectToOrganizationSettingsAsync(null, "googleDriveDam=error&reason=missing_code");
|
||||
return;
|
||||
}
|
||||
|
||||
GoogleDriveDamOAuthState state;
|
||||
try
|
||||
{
|
||||
state = oauthService.UnprotectState(protectedState);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
await RedirectToOrganizationSettingsAsync(null, "googleDriveDam=error&reason=invalid_state");
|
||||
return;
|
||||
}
|
||||
|
||||
Organization? organization = await dbContext.Organizations
|
||||
.SingleOrDefaultAsync(candidate => candidate.Id == state.OrganizationId, ct);
|
||||
if (organization is null)
|
||||
{
|
||||
await RedirectToOrganizationSettingsAsync(state.OrganizationId, "googleDriveDam=error&reason=organization_not_found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await StateUserCanManageConnectorsAsync(organization, state.UserId, ct))
|
||||
{
|
||||
await RedirectToOrganizationSettingsAsync(state.OrganizationId, "googleDriveDam=error&reason=forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
GoogleDriveDamTokenResult tokenResult;
|
||||
try
|
||||
{
|
||||
tokenResult = await oauthService.ExchangeCodeAsync(
|
||||
code,
|
||||
oauthService.GetRedirectUri(HttpContext.Request),
|
||||
ct);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
await RedirectToOrganizationSettingsAsync(state.OrganizationId, "googleDriveDam=error&reason=token_exchange_failed");
|
||||
return;
|
||||
}
|
||||
|
||||
organization.GoogleDriveAccessToken = tokenResult.AccessToken;
|
||||
organization.GoogleDriveRefreshToken = tokenResult.RefreshToken ?? organization.GoogleDriveRefreshToken;
|
||||
organization.GoogleDriveClientId = oauthService.GetClientIdForStorage();
|
||||
organization.GoogleDriveClientSecret = oauthService.GetClientSecretForStorage();
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await RedirectToOrganizationSettingsAsync(state.OrganizationId, "googleDriveDam=connected");
|
||||
}
|
||||
|
||||
private Task RedirectToOrganizationSettingsAsync(Guid? organizationId, string queryString)
|
||||
{
|
||||
string baseUrl = websiteOptions.Value.FrontendBaseUrl.TrimEnd('/');
|
||||
string path = organizationId.HasValue
|
||||
? $"/app/organizations/{organizationId.Value}/settings"
|
||||
: "/app/settings/integrations";
|
||||
HttpContext.Response.Redirect($"{baseUrl}{path}?{queryString}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task<bool> StateUserCanManageConnectorsAsync(
|
||||
Organization organization,
|
||||
Guid userId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (organization.OwnerUserId == userId)
|
||||
{
|
||||
return OrganizationPermissionRules.RoleHasPermission(
|
||||
OrganizationRoles.Owner,
|
||||
OrganizationPermissions.ManageConnectors);
|
||||
}
|
||||
|
||||
string[] roles = await dbContext.OrganizationMemberships
|
||||
.Where(membership => membership.OrganizationId == organization.Id && membership.UserId == userId)
|
||||
.Select(membership => membership.Role)
|
||||
.ToArrayAsync(ct);
|
||||
|
||||
return roles.Any(role => OrganizationPermissionRules.RoleHasPermission(
|
||||
role,
|
||||
OrganizationPermissions.ManageConnectors));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class DisconnectGoogleDriveDamOAuthHandler(
|
||||
AppDbContext dbContext,
|
||||
OrganizationAccessService organizationAccessService)
|
||||
: EndpointWithoutRequest<OrganizationGoogleDriveDamConfigurationDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/api/organizations/{organizationId:guid}/google-drive-dam/oauth");
|
||||
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.HasOrganizationPermissionAsync(
|
||||
User,
|
||||
organizationId,
|
||||
OrganizationPermissions.ManageConnectors,
|
||||
ct))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
organization.GoogleDriveAccessToken = null;
|
||||
organization.GoogleDriveRefreshToken = null;
|
||||
organization.GoogleDriveClientId = null;
|
||||
organization.GoogleDriveClientSecret = null;
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(OrganizationGoogleDriveDamConfigurationDto.FromOrganization(organization), ct);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Socialize.Api.Modules.Organizations.Configuration;
|
||||
using Socialize.Api.Modules.Organizations.Services;
|
||||
|
||||
namespace Socialize.Api.Modules.Organizations;
|
||||
@@ -7,7 +8,10 @@ internal static class ModuleRegistration
|
||||
public static WebApplicationBuilder AddOrganizationsModule(
|
||||
this WebApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.Configure<GoogleDriveDamOAuthOptions>(
|
||||
builder.Configuration.GetSection(GoogleDriveDamOAuthOptions.SectionName));
|
||||
builder.Services.AddScoped<OrganizationAccessService>();
|
||||
builder.Services.AddScoped<GoogleDriveDamOAuthService>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Socialize.Api.Modules.Organizations.Configuration;
|
||||
|
||||
namespace Socialize.Api.Modules.Organizations.Services;
|
||||
|
||||
internal sealed record GoogleDriveDamOAuthState(
|
||||
Guid OrganizationId,
|
||||
Guid UserId,
|
||||
DateTimeOffset ExpiresAt);
|
||||
|
||||
internal sealed record GoogleDriveDamTokenResult(
|
||||
string AccessToken,
|
||||
string? RefreshToken,
|
||||
int? ExpiresIn);
|
||||
|
||||
internal sealed class GoogleDriveDamOAuthService(
|
||||
IConfiguration configuration,
|
||||
IDataProtectionProvider dataProtectionProvider,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptionsSnapshot<GoogleDriveDamOAuthOptions> options)
|
||||
{
|
||||
private const string DriveScope = "https://www.googleapis.com/auth/drive";
|
||||
private static readonly Uri AuthorizationUri = new("https://accounts.google.com/o/oauth2/v2/auth");
|
||||
private static readonly Uri TokenUri = new("https://oauth2.googleapis.com/token");
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly IDataProtector _stateProtector = dataProtectionProvider.CreateProtector(
|
||||
"Socialize.GoogleDriveDamOAuth.State.v1");
|
||||
|
||||
public bool IsConfigured => !string.IsNullOrWhiteSpace(GetClientId()) &&
|
||||
!string.IsNullOrWhiteSpace(GetClientSecret());
|
||||
|
||||
public string BuildAuthorizationUrl(
|
||||
Guid organizationId,
|
||||
Guid userId,
|
||||
string redirectUri)
|
||||
{
|
||||
string clientId = GetRequiredClientId();
|
||||
string state = ProtectState(new GoogleDriveDamOAuthState(
|
||||
organizationId,
|
||||
userId,
|
||||
DateTimeOffset.UtcNow.AddMinutes(15)));
|
||||
Dictionary<string, string?> values = new()
|
||||
{
|
||||
["client_id"] = clientId,
|
||||
["redirect_uri"] = redirectUri,
|
||||
["response_type"] = "code",
|
||||
["scope"] = DriveScope,
|
||||
["access_type"] = "offline",
|
||||
["prompt"] = "consent",
|
||||
["include_granted_scopes"] = "true",
|
||||
["state"] = state,
|
||||
};
|
||||
|
||||
return $"{AuthorizationUri}?{BuildQueryString(values)}";
|
||||
}
|
||||
|
||||
public async Task<GoogleDriveDamTokenResult> ExchangeCodeAsync(
|
||||
string code,
|
||||
string redirectUri,
|
||||
CancellationToken ct)
|
||||
{
|
||||
using FormUrlEncodedContent content = new(
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["client_id"] = GetRequiredClientId(),
|
||||
["client_secret"] = GetRequiredClientSecret(),
|
||||
["code"] = code,
|
||||
["grant_type"] = "authorization_code",
|
||||
["redirect_uri"] = redirectUri,
|
||||
});
|
||||
using HttpClient httpClient = httpClientFactory.CreateClient();
|
||||
using HttpResponseMessage response = await httpClient.PostAsync(TokenUri, content, ct);
|
||||
string body = await response.Content.ReadAsStringAsync(ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Google Drive OAuth token exchange failed with {(int)response.StatusCode}: {body}");
|
||||
}
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(body);
|
||||
string accessToken = document.RootElement.GetProperty("access_token").GetString() ??
|
||||
throw new InvalidOperationException("Google OAuth token response did not include an access token.");
|
||||
string? refreshToken = document.RootElement.TryGetProperty("refresh_token", out JsonElement refreshElement)
|
||||
? refreshElement.GetString()
|
||||
: null;
|
||||
int? expiresIn = document.RootElement.TryGetProperty("expires_in", out JsonElement expiresElement)
|
||||
? expiresElement.GetInt32()
|
||||
: null;
|
||||
|
||||
return new GoogleDriveDamTokenResult(accessToken, refreshToken, expiresIn);
|
||||
}
|
||||
|
||||
public GoogleDriveDamOAuthState UnprotectState(string protectedState)
|
||||
{
|
||||
string json = _stateProtector.Unprotect(protectedState);
|
||||
GoogleDriveDamOAuthState? state = JsonSerializer.Deserialize<GoogleDriveDamOAuthState>(json, JsonOptions);
|
||||
if (state is null || state.ExpiresAt < DateTimeOffset.UtcNow)
|
||||
{
|
||||
throw new InvalidOperationException("Google Drive OAuth state is invalid or expired.");
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
public string GetClientIdForStorage()
|
||||
{
|
||||
return GetRequiredClientId();
|
||||
}
|
||||
|
||||
public string GetClientSecretForStorage()
|
||||
{
|
||||
return GetRequiredClientSecret();
|
||||
}
|
||||
|
||||
public string GetRedirectUri(HttpRequest request)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.Value.RedirectUri))
|
||||
{
|
||||
return options.Value.RedirectUri.Trim();
|
||||
}
|
||||
|
||||
return $"{request.Scheme}://{request.Host}{request.PathBase}/api/organizations/google-drive-dam/oauth/callback";
|
||||
}
|
||||
|
||||
private string ProtectState(GoogleDriveDamOAuthState state)
|
||||
{
|
||||
string json = JsonSerializer.Serialize(state, JsonOptions);
|
||||
return _stateProtector.Protect(json);
|
||||
}
|
||||
|
||||
private string GetRequiredClientId()
|
||||
{
|
||||
return GetClientId() ??
|
||||
throw new InvalidOperationException("Google Drive DAM OAuth client id is not configured.");
|
||||
}
|
||||
|
||||
private string GetRequiredClientSecret()
|
||||
{
|
||||
return GetClientSecret() ??
|
||||
throw new InvalidOperationException("Google Drive DAM OAuth client secret is not configured.");
|
||||
}
|
||||
|
||||
private string? GetClientId()
|
||||
{
|
||||
return Normalize(options.Value.ClientId) ??
|
||||
Normalize(configuration["Authentication:Google:ClientId"]);
|
||||
}
|
||||
|
||||
private string? GetClientSecret()
|
||||
{
|
||||
return Normalize(options.Value.ClientSecret) ??
|
||||
Normalize(configuration["Authentication:Google:ClientSecret"]);
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static string BuildQueryString(Dictionary<string, string?> values)
|
||||
{
|
||||
return string.Join(
|
||||
"&",
|
||||
values
|
||||
.Where(pair => !string.IsNullOrWhiteSpace(pair.Value))
|
||||
.Select(pair => $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value!)}"));
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,26 @@ An organization Google Drive configuration includes:
|
||||
- OAuth refresh token for durable read/write access
|
||||
- optional temporary access token for local/testing access
|
||||
|
||||
The customer-facing setup path is OAuth based. A connector manager enables Google
|
||||
Drive DAM, enters the organization Drive root folder metadata, then uses Connect
|
||||
Google Drive to grant Socialize read/write Drive access. The server must be
|
||||
configured with a Google OAuth web client before this button can issue an
|
||||
authorization URL:
|
||||
|
||||
- `GoogleDriveDamOAuth:ClientId`
|
||||
- `GoogleDriveDamOAuth:ClientSecret`
|
||||
- optional `GoogleDriveDamOAuth:RedirectUri`
|
||||
|
||||
When `GoogleDriveDamOAuth:RedirectUri` is omitted, Socialize uses:
|
||||
|
||||
```txt
|
||||
<api-origin>/api/organizations/google-drive-dam/oauth/callback
|
||||
```
|
||||
|
||||
That callback URL must be registered as an authorized redirect URI on the Google
|
||||
OAuth client. Socialize requests the Drive read/write scope and stores the
|
||||
returned refresh token for durable DAM access.
|
||||
|
||||
Workspace media is organized inside the organization Drive root by workspace slug:
|
||||
|
||||
```txt
|
||||
@@ -56,6 +76,7 @@ The workspace DAM view should expose:
|
||||
- Google Drive DAM requires credentials with read/write access to the configured root folder.
|
||||
- Google Drive backed DAM operations must stay scoped to `<organization-drive-root>/<workspace-slug>/medias/`.
|
||||
- Socialize blob-storage DAM operations must stay scoped to the workspace DAM media prefix.
|
||||
- Google Drive DAM OAuth requires a server-side Google OAuth web client; customers should not paste access tokens during the normal setup path.
|
||||
|
||||
## Out Of Scope For First Slice
|
||||
|
||||
|
||||
35
docs/TASKS/content/011-google-drive-dam-oauth-connect.md
Normal file
35
docs/TASKS/content/011-google-drive-dam-oauth-connect.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Task: Add Google Drive DAM OAuth connect flow
|
||||
|
||||
## Feature
|
||||
|
||||
`docs/FEATURES/digital-asset-management.md`
|
||||
|
||||
## Goal
|
||||
|
||||
Let connector managers authorize Google Drive DAM from organization settings instead of manually pasting access tokens.
|
||||
|
||||
## Scope
|
||||
|
||||
- Add server configuration for the Google Drive DAM OAuth web client.
|
||||
- Add a start endpoint that returns a Google authorization URL for connector managers.
|
||||
- Add an OAuth callback endpoint that stores access and refresh token credentials for the organization.
|
||||
- Add a disconnect endpoint that clears stored Google Drive DAM credentials.
|
||||
- Add organization settings UI actions for Connect Google Drive and Disconnect Google Drive.
|
||||
- Keep manual credential entry available as an admin fallback.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
dotnet build backend/Socialize.slnx
|
||||
dotnet test backend/Socialize.slnx
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Done
|
||||
|
||||
- [x] Google Drive DAM OAuth server options exist.
|
||||
- [x] Connector managers can start Google OAuth authorization from organization settings.
|
||||
- [x] The OAuth callback stores organization Google Drive credentials.
|
||||
- [x] Connector managers can disconnect stored Google Drive DAM credentials.
|
||||
- [x] Organization settings explain the OAuth path and keep manual credentials as a fallback.
|
||||
147
frontend/src/api/schema.d.ts
vendored
147
frontend/src/api/schema.d.ts
vendored
@@ -388,6 +388,54 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/organizations/{organizationId}/google-drive-dam/oauth/start": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["SocializeApiModulesOrganizationsHandlersStartGoogleDriveDamOAuthHandler"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/organizations/google-drive-dam/oauth/callback": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["SocializeApiModulesOrganizationsHandlersGoogleDriveDamOAuthCallbackHandler"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/organizations/{organizationId}/google-drive-dam/oauth": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete: operations["SocializeApiModulesOrganizationsHandlersDisconnectGoogleDriveDamOAuthHandler"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/organizations": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1706,6 +1754,20 @@ export interface components {
|
||||
/** Format: binary */
|
||||
file: string;
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersStartGoogleDriveDamOAuthResponse: {
|
||||
isConfigured?: boolean;
|
||||
authorizationUrl?: string | null;
|
||||
message?: string | null;
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersOrganizationGoogleDriveDamConfigurationDto: {
|
||||
isEnabled?: boolean;
|
||||
rootFolderId?: string | null;
|
||||
rootFolderName?: string | null;
|
||||
rootFolderUrl?: string | null;
|
||||
hasAccessToken?: boolean;
|
||||
hasRefreshToken?: boolean;
|
||||
hasClientCredentials?: boolean;
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersOrganizationDto: {
|
||||
/** Format: guid */
|
||||
id?: string;
|
||||
@@ -1723,15 +1785,6 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
createdAt?: string;
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersOrganizationGoogleDriveDamConfigurationDto: {
|
||||
isEnabled?: boolean;
|
||||
rootFolderId?: string | null;
|
||||
rootFolderName?: string | null;
|
||||
rootFolderUrl?: string | null;
|
||||
hasAccessToken?: boolean;
|
||||
hasRefreshToken?: boolean;
|
||||
hasClientCredentials?: boolean;
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersOrganizationMembershipTierDto: {
|
||||
/** Format: guid */
|
||||
id?: string;
|
||||
@@ -3530,6 +3583,82 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersStartGoogleDriveDamOAuthHandler: {
|
||||
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"]["SocializeApiModulesOrganizationsHandlersStartGoogleDriveDamOAuthResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersGoogleDriveDamOAuthCallbackHandler: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description No Content */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersDisconnectGoogleDriveDamOAuthHandler: {
|
||||
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"]["SocializeApiModulesOrganizationsHandlersOrganizationGoogleDriveDamConfigurationDto"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
SocializeApiModulesOrganizationsHandlersGetOrganizationsHandler: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -300,6 +300,54 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function startGoogleDriveDamOAuth(organizationId) {
|
||||
if (!authStore.isAuthenticated || !organizationId) {
|
||||
throw new Error('You must be authenticated to connect Google Drive.');
|
||||
}
|
||||
|
||||
const response = await client.get(`/api/organizations/${organizationId}/google-drive-dam/oauth/start`);
|
||||
return response.data ?? null;
|
||||
}
|
||||
|
||||
async function disconnectGoogleDriveDamOAuth(organizationId) {
|
||||
if (!authStore.isAuthenticated || !organizationId) {
|
||||
throw new Error('You must be authenticated to disconnect Google Drive.');
|
||||
}
|
||||
|
||||
isSavingGoogleDriveDam.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await client.delete(`/api/organizations/${organizationId}/google-drive-dam/oauth`);
|
||||
const googleDriveDam = response.data;
|
||||
const currentDetails = detailsById.value[organizationId];
|
||||
|
||||
if (currentDetails) {
|
||||
detailsById.value = {
|
||||
...detailsById.value,
|
||||
[organizationId]: {
|
||||
...currentDetails,
|
||||
googleDriveDam,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
organizations.value = organizations.value.map(organization =>
|
||||
organization.id === organizationId
|
||||
? { ...organization, googleDriveDam }
|
||||
: organization
|
||||
);
|
||||
|
||||
return googleDriveDam;
|
||||
} catch (disconnectError) {
|
||||
console.error('Failed to disconnect Google Drive DAM:', disconnectError);
|
||||
error.value = 'Failed to disconnect Google Drive DAM.';
|
||||
throw disconnectError;
|
||||
} finally {
|
||||
isSavingGoogleDriveDam.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addMember(organizationId, payload) {
|
||||
if (!authStore.isAuthenticated || !organizationId) {
|
||||
throw new Error('You must be authenticated to add an organization member.');
|
||||
@@ -425,6 +473,8 @@ export const useOrganizationStore = defineStore('organization', () => {
|
||||
updateOrganization,
|
||||
updateMembershipTier,
|
||||
updateGoogleDriveDam,
|
||||
startGoogleDriveDamOAuth,
|
||||
disconnectGoogleDriveDamOAuth,
|
||||
addMember,
|
||||
uploadLogo,
|
||||
};
|
||||
|
||||
@@ -256,6 +256,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function connectGoogleDriveDam() {
|
||||
settingsError.value = null;
|
||||
settingsStatus.value = null;
|
||||
|
||||
try {
|
||||
const response = await organizationStore.startGoogleDriveDamOAuth(organizationId.value);
|
||||
if (!response?.isConfigured || !response?.authorizationUrl) {
|
||||
settingsError.value = response?.message ?? t('organizationSettings.sections.connections.googleDrive.oauthNotConfigured');
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.assign(response.authorizationUrl);
|
||||
} catch (error) {
|
||||
console.error('Failed to start Google Drive DAM OAuth:', error);
|
||||
settingsError.value = t('organizationSettings.sections.connections.googleDrive.oauthStartFailed');
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectGoogleDriveDam() {
|
||||
settingsError.value = null;
|
||||
settingsStatus.value = null;
|
||||
|
||||
try {
|
||||
await organizationStore.disconnectGoogleDriveDamOAuth(organizationId.value);
|
||||
settingsStatus.value = t('organizationSettings.sections.connections.googleDrive.disconnected');
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect Google Drive DAM OAuth:', error);
|
||||
settingsError.value = t('organizationSettings.sections.connections.googleDrive.disconnectFailed');
|
||||
}
|
||||
}
|
||||
|
||||
function formatTierSummary(tier) {
|
||||
const price = tier.isCustom || tier.monthlyPriceCents === null || tier.monthlyPriceCents === undefined
|
||||
? t('organizationSettings.tiers.customPrice')
|
||||
@@ -285,7 +316,18 @@
|
||||
return Math.min(100, Math.round((item.used / item.limit) * 100));
|
||||
}
|
||||
|
||||
onMounted(loadOrganization);
|
||||
async function loadOrganizationSettings() {
|
||||
await loadOrganization();
|
||||
if (route.query.googleDriveDam === 'connected') {
|
||||
activeSectionKey.value = 'connections';
|
||||
settingsStatus.value = t('organizationSettings.sections.connections.googleDrive.connected');
|
||||
} else if (route.query.googleDriveDam === 'error') {
|
||||
activeSectionKey.value = 'connections';
|
||||
settingsError.value = t('organizationSettings.sections.connections.googleDrive.oauthFailed');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadOrganizationSettings);
|
||||
|
||||
watch(organizationId, loadOrganization);
|
||||
watch(
|
||||
@@ -594,45 +636,67 @@
|
||||
: t('organizationSettings.sections.connections.googleDrive.credentialsMissing') }}
|
||||
</strong>
|
||||
</div>
|
||||
<v-text-field
|
||||
v-model="googleDriveDamForm.clientId"
|
||||
:label="t('organizationSettings.sections.connections.googleDrive.clientId')"
|
||||
:hint="t('organizationSettings.sections.connections.googleDrive.clientIdHint')"
|
||||
:disabled="!googleDriveDamForm.isEnabled || organizationStore.isSavingGoogleDriveDam"
|
||||
maxlength="512"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="googleDriveDamForm.clientSecret"
|
||||
:label="t('organizationSettings.sections.connections.googleDrive.clientSecret')"
|
||||
:hint="t('organizationSettings.sections.connections.googleDrive.clientSecretHint')"
|
||||
:disabled="!googleDriveDamForm.isEnabled || organizationStore.isSavingGoogleDriveDam"
|
||||
maxlength="2048"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="googleDriveDamForm.refreshToken"
|
||||
:label="t('organizationSettings.sections.connections.googleDrive.refreshToken')"
|
||||
:hint="t('organizationSettings.sections.connections.googleDrive.refreshTokenHint')"
|
||||
:disabled="!googleDriveDamForm.isEnabled || organizationStore.isSavingGoogleDriveDam"
|
||||
maxlength="4096"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="googleDriveDamForm.accessToken"
|
||||
:label="t('organizationSettings.sections.connections.googleDrive.accessToken')"
|
||||
:hint="t('organizationSettings.sections.connections.googleDrive.accessTokenHint')"
|
||||
:disabled="!googleDriveDamForm.isEnabled || organizationStore.isSavingGoogleDriveDam"
|
||||
maxlength="4096"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
/>
|
||||
<div class="oauth-actions">
|
||||
<v-btn
|
||||
color="primary"
|
||||
type="button"
|
||||
:disabled="!googleDriveDamForm.isEnabled || organizationStore.isSavingGoogleDriveDam"
|
||||
@click="connectGoogleDriveDam"
|
||||
>
|
||||
{{ t('organizationSettings.sections.connections.googleDrive.connect') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="tonal"
|
||||
color="error"
|
||||
type="button"
|
||||
:disabled="organizationStore.isSavingGoogleDriveDam || (!organization?.googleDriveDam?.hasAccessToken && !organization?.googleDriveDam?.hasRefreshToken)"
|
||||
@click="disconnectGoogleDriveDam"
|
||||
>
|
||||
{{ t('organizationSettings.sections.connections.googleDrive.disconnect') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
<details class="manual-credentials">
|
||||
<summary>{{ t('organizationSettings.sections.connections.googleDrive.manualFallback') }}</summary>
|
||||
<v-text-field
|
||||
v-model="googleDriveDamForm.clientId"
|
||||
:label="t('organizationSettings.sections.connections.googleDrive.clientId')"
|
||||
:hint="t('organizationSettings.sections.connections.googleDrive.clientIdHint')"
|
||||
:disabled="!googleDriveDamForm.isEnabled || organizationStore.isSavingGoogleDriveDam"
|
||||
maxlength="512"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="googleDriveDamForm.clientSecret"
|
||||
:label="t('organizationSettings.sections.connections.googleDrive.clientSecret')"
|
||||
:hint="t('organizationSettings.sections.connections.googleDrive.clientSecretHint')"
|
||||
:disabled="!googleDriveDamForm.isEnabled || organizationStore.isSavingGoogleDriveDam"
|
||||
maxlength="2048"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="googleDriveDamForm.refreshToken"
|
||||
:label="t('organizationSettings.sections.connections.googleDrive.refreshToken')"
|
||||
:hint="t('organizationSettings.sections.connections.googleDrive.refreshTokenHint')"
|
||||
:disabled="!googleDriveDamForm.isEnabled || organizationStore.isSavingGoogleDriveDam"
|
||||
maxlength="4096"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="googleDriveDamForm.accessToken"
|
||||
:label="t('organizationSettings.sections.connections.googleDrive.accessToken')"
|
||||
:hint="t('organizationSettings.sections.connections.googleDrive.accessTokenHint')"
|
||||
:disabled="!googleDriveDamForm.isEnabled || organizationStore.isSavingGoogleDriveDam"
|
||||
maxlength="4096"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
/>
|
||||
</details>
|
||||
<v-btn
|
||||
color="primary"
|
||||
type="submit"
|
||||
@@ -1018,6 +1082,25 @@
|
||||
color: var(--app-color-on-surface);
|
||||
}
|
||||
|
||||
.oauth-actions {
|
||||
@apply flex flex-wrap items-center gap-2;
|
||||
}
|
||||
|
||||
.manual-credentials {
|
||||
@apply rounded-[0.75rem] border p-4;
|
||||
background: var(--app-color-on-primary);
|
||||
border-color: var(--app-border-subtle);
|
||||
}
|
||||
|
||||
.manual-credentials summary {
|
||||
@apply cursor-pointer text-sm font-semibold;
|
||||
color: var(--app-color-on-surface);
|
||||
}
|
||||
|
||||
.manual-credentials[open] {
|
||||
@apply flex flex-col gap-4;
|
||||
}
|
||||
|
||||
.usage-list {
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
"copyMetadata": "Copy the folder name, folder ID from the Drive URL, and full folder URL into the fields below.",
|
||||
"workspaceFolders": "Socialize will use Drive root/workspace slug/medias for browse, read, and write operations."
|
||||
},
|
||||
"tokenNote": "Use OAuth client credentials plus a refresh token for durable access. A pasted access token is only useful for short-lived testing.",
|
||||
"tokenNote": "Use Connect Google Drive for durable OAuth access. Manual tokens are only a fallback for local testing or one-off support.",
|
||||
"enabled": "Use Google Drive as the DAM backing store",
|
||||
"rootFolderName": "Root folder name",
|
||||
"rootFolderNameHint": "Example: Acme Social Media Library",
|
||||
@@ -528,6 +528,15 @@
|
||||
"refreshTokenConfigured": "OAuth refresh credentials configured",
|
||||
"accessTokenConfigured": "Short-lived access token configured",
|
||||
"credentialsMissing": "Credentials missing",
|
||||
"connect": "Connect Google Drive",
|
||||
"connected": "Google Drive connected.",
|
||||
"disconnect": "Disconnect Google Drive",
|
||||
"disconnected": "Google Drive disconnected.",
|
||||
"disconnectFailed": "Google Drive could not be disconnected.",
|
||||
"oauthNotConfigured": "Google Drive OAuth is not configured on the server.",
|
||||
"oauthStartFailed": "Google Drive authorization could not be started.",
|
||||
"oauthFailed": "Google Drive authorization did not complete.",
|
||||
"manualFallback": "Manual credential fallback",
|
||||
"clientId": "OAuth client ID",
|
||||
"clientIdHint": "Google Cloud OAuth client ID with Drive API enabled.",
|
||||
"clientSecret": "OAuth client secret",
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
"copyMetadata": "Copiez le nom du dossier, l'ID du dossier depuis l'URL Drive et l'URL complete dans les champs ci-dessous.",
|
||||
"workspaceFolders": "Socialize utilisera racine Drive/slug espace/medias pour parcourir, lire et ecrire."
|
||||
},
|
||||
"tokenNote": "Utilisez les identifiants OAuth et un jeton d'actualisation pour un acces durable. Un jeton d'acces colle ne sert qu'aux tests temporaires.",
|
||||
"tokenNote": "Utilisez Connecter Google Drive pour un acces OAuth durable. Les jetons manuels sont seulement un recours pour les tests locaux ou le support ponctuel.",
|
||||
"enabled": "Utiliser Google Drive comme stockage DAM",
|
||||
"rootFolderName": "Nom du dossier racine",
|
||||
"rootFolderNameHint": "Exemple : Bibliotheque media Acme",
|
||||
@@ -528,6 +528,15 @@
|
||||
"refreshTokenConfigured": "Identifiants OAuth avec jeton d'actualisation configures",
|
||||
"accessTokenConfigured": "Jeton d'acces temporaire configure",
|
||||
"credentialsMissing": "Identifiants manquants",
|
||||
"connect": "Connecter Google Drive",
|
||||
"connected": "Google Drive connecte.",
|
||||
"disconnect": "Deconnecter Google Drive",
|
||||
"disconnected": "Google Drive deconnecte.",
|
||||
"disconnectFailed": "Google Drive n'a pas pu etre deconnecte.",
|
||||
"oauthNotConfigured": "OAuth Google Drive n'est pas configure sur le serveur.",
|
||||
"oauthStartFailed": "L'autorisation Google Drive n'a pas pu demarrer.",
|
||||
"oauthFailed": "L'autorisation Google Drive ne s'est pas terminee.",
|
||||
"manualFallback": "Recours manuel pour les identifiants",
|
||||
"clientId": "ID client OAuth",
|
||||
"clientIdHint": "ID client OAuth Google Cloud avec l'API Drive activee.",
|
||||
"clientSecret": "Secret client OAuth",
|
||||
|
||||
@@ -1266,6 +1266,100 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/organizations/{organizationId}/google-drive-dam/oauth/start": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Organizations",
|
||||
"Api"
|
||||
],
|
||||
"operationId": "SocializeApiModulesOrganizationsHandlersStartGoogleDriveDamOAuthHandler",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "organizationId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "guid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SocializeApiModulesOrganizationsHandlersStartGoogleDriveDamOAuthResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"JWTBearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/organizations/google-drive-dam/oauth/callback": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Organizations",
|
||||
"Api"
|
||||
],
|
||||
"operationId": "SocializeApiModulesOrganizationsHandlersGoogleDriveDamOAuthCallbackHandler",
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/organizations/{organizationId}/google-drive-dam/oauth": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Organizations",
|
||||
"Api"
|
||||
],
|
||||
"operationId": "SocializeApiModulesOrganizationsHandlersDisconnectGoogleDriveDamOAuthHandler",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "organizationId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "guid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SocializeApiModulesOrganizationsHandlersOrganizationGoogleDriveDamConfigurationDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"JWTBearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/organizations": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -5715,6 +5809,53 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SocializeApiModulesOrganizationsHandlersStartGoogleDriveDamOAuthResponse": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"isConfigured": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"authorizationUrl": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"SocializeApiModulesOrganizationsHandlersOrganizationGoogleDriveDamConfigurationDto": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"isEnabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"rootFolderId": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"rootFolderName": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"rootFolderUrl": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"hasAccessToken": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasRefreshToken": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasClientCredentials": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SocializeApiModulesOrganizationsHandlersOrganizationDto": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
@@ -5783,36 +5924,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SocializeApiModulesOrganizationsHandlersOrganizationGoogleDriveDamConfigurationDto": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"isEnabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"rootFolderId": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"rootFolderName": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"rootFolderUrl": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"hasAccessToken": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasRefreshToken": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasClientCredentials": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SocializeApiModulesOrganizationsHandlersOrganizationMembershipTierDto": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
Reference in New Issue
Block a user