feat: add google drive dam oauth connect

This commit is contained in:
2026-05-08 17:04:41 -04:00
parent 39a68a71cd
commit 85054d2113
12 changed files with 924 additions and 81 deletions

View File

@@ -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; }
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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!)}"));
}
}

View File

@@ -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

View 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.

View File

@@ -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;

View File

@@ -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,
};

View File

@@ -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;
}

View File

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

View File

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

View File

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