Skip to content

Commit 458af33

Browse files
committed
chore: merge main into release for new releases
2 parents 1676f63 + dc484d8 commit 458af33

5 files changed

Lines changed: 325 additions & 37 deletions

File tree

‎apps/api/src/integration-platform/controllers/sync.controller.ts‎

Lines changed: 139 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,54 @@ interface RampUsersResponse {
5353
};
5454
}
5555

56+
type GoogleWorkspaceSyncFilterMode = 'all' | 'exclude' | 'include';
57+
58+
const GOOGLE_WORKSPACE_SYNC_FILTER_MODES =
59+
new Set<GoogleWorkspaceSyncFilterMode>(['all', 'exclude', 'include']);
60+
61+
const parseSyncFilterTerms = (value: unknown): string[] => {
62+
const rawValues = Array.isArray(value)
63+
? value.map((item) => String(item))
64+
: typeof value === 'string'
65+
? [value]
66+
: [];
67+
68+
return Array.from(
69+
new Set(
70+
rawValues
71+
.flatMap((item) => item.split(/[\n,;]+/))
72+
.map((item) => item.trim().toLowerCase())
73+
.filter((item) => item.length > 0),
74+
),
75+
);
76+
};
77+
78+
const isFullEmailTerm = (term: string): boolean =>
79+
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(term);
80+
81+
const matchesSyncFilterTerm = (email: string, term: string): boolean => {
82+
if (email === term) {
83+
return true;
84+
}
85+
86+
if (term.startsWith('@')) {
87+
return email.endsWith(term);
88+
}
89+
90+
if (isFullEmailTerm(term)) {
91+
return false;
92+
}
93+
94+
if (term.includes('@')) {
95+
return email.includes(term);
96+
}
97+
98+
return email.endsWith(`@${term}`) || email.includes(term);
99+
};
100+
101+
const matchesSyncFilterTerms = (email: string, terms: string[]): boolean =>
102+
terms.some((term) => matchesSyncFilterTerm(email, term));
103+
56104
@Controller({ path: 'integrations/sync', version: '1' })
57105
export class SyncController {
58106
private readonly logger = new Logger(SyncController.name);
@@ -225,17 +273,72 @@ export class SyncController {
225273
);
226274
}
227275

228-
// Separate active and suspended users
229-
const activeUsers = users.filter((u) => !u.suspended);
230-
const suspendedEmails = new Set(
231-
users.filter((u) => u.suspended).map((u) => u.primaryEmail.toLowerCase()),
276+
const syncVariables = (connection.variables || {}) as Record<
277+
string,
278+
unknown
279+
>;
280+
const rawSyncFilterMode = syncVariables.sync_user_filter_mode;
281+
const syncFilterMode: GoogleWorkspaceSyncFilterMode =
282+
typeof rawSyncFilterMode === 'string' &&
283+
GOOGLE_WORKSPACE_SYNC_FILTER_MODES.has(
284+
rawSyncFilterMode as GoogleWorkspaceSyncFilterMode,
285+
)
286+
? (rawSyncFilterMode as GoogleWorkspaceSyncFilterMode)
287+
: 'all';
288+
const excludedTerms = parseSyncFilterTerms(
289+
syncVariables.sync_excluded_emails,
232290
);
233-
const activeEmails = new Set(
291+
const includedTerms = parseSyncFilterTerms(
292+
syncVariables.sync_included_emails,
293+
);
294+
295+
let effectiveSyncFilterMode = syncFilterMode;
296+
if (syncFilterMode === 'include' && includedTerms.length === 0) {
297+
this.logger.warn(
298+
`Google Workspace sync for org ${organizationId} is set to include mode, but include list is empty. Falling back to all users.`,
299+
);
300+
effectiveSyncFilterMode = 'all';
301+
}
302+
303+
const filteredUsers = users.filter((user) => {
304+
const email = user.primaryEmail.toLowerCase();
305+
306+
if (effectiveSyncFilterMode === 'exclude' && excludedTerms.length > 0) {
307+
return !matchesSyncFilterTerms(email, excludedTerms);
308+
}
309+
310+
if (effectiveSyncFilterMode === 'include') {
311+
return matchesSyncFilterTerms(email, includedTerms);
312+
}
313+
314+
return true;
315+
});
316+
317+
this.logger.log(
318+
`Google Workspace sync filter mode "${effectiveSyncFilterMode}" kept ${filteredUsers.length}/${users.length} users`,
319+
);
320+
321+
// Active users to import/reactivate are based on the selected filter mode
322+
const activeUsers = filteredUsers.filter((u) => !u.suspended);
323+
const filteredSuspendedEmails = new Set(
324+
filteredUsers
325+
.filter((u) => u.suspended)
326+
.map((u) => u.primaryEmail.toLowerCase()),
327+
);
328+
const filteredActiveEmails = new Set(
234329
activeUsers.map((u) => u.primaryEmail.toLowerCase()),
235330
);
331+
const allSuspendedEmails = new Set(
332+
users.filter((u) => u.suspended).map((u) => u.primaryEmail.toLowerCase()),
333+
);
334+
const allActiveEmails = new Set(
335+
users
336+
.filter((u) => !u.suspended)
337+
.map((u) => u.primaryEmail.toLowerCase()),
338+
);
236339

237340
this.logger.log(
238-
`Found ${activeUsers.length} active users and ${suspendedEmails.size} suspended users in Google Workspace`,
341+
`Found ${activeUsers.length} active users and ${filteredSuspendedEmails.size} suspended users in Google Workspace after sync filtering`,
239342
);
240343

241344
// Import users into the organization
@@ -355,25 +458,46 @@ export class SyncController {
355458
},
356459
});
357460

358-
// Get the domains from ALL users (including suspended) to track which domains Google Workspace manages
359-
// This ensures members get deactivated even when an entire domain has no active users
360-
const gwDomains = new Set(
361-
users.map((u) => u.primaryEmail.split('@')[1]?.toLowerCase()),
362-
);
461+
const deactivationGwDomains =
462+
effectiveSyncFilterMode === 'include'
463+
? new Set(users.map((u) => u.primaryEmail.split('@')[1]?.toLowerCase()))
464+
: new Set(
465+
filteredUsers.map((u) =>
466+
u.primaryEmail.split('@')[1]?.toLowerCase(),
467+
),
468+
);
469+
const deactivationSuspendedEmails =
470+
effectiveSyncFilterMode === 'include'
471+
? allSuspendedEmails
472+
: filteredSuspendedEmails;
473+
const deactivationActiveEmails =
474+
effectiveSyncFilterMode === 'include'
475+
? allActiveEmails
476+
: filteredActiveEmails;
363477

364478
for (const member of allOrgMembers) {
365479
const memberEmail = member.user.email.toLowerCase();
366480
const memberDomain = memberEmail.split('@')[1];
367481

368482
// Only check members whose email domain matches the Google Workspace domain
369-
if (!memberDomain || !gwDomains.has(memberDomain)) {
483+
if (!memberDomain || !deactivationGwDomains.has(memberDomain)) {
484+
continue;
485+
}
486+
487+
// In exclude mode we keep excluded users unchanged and only stop syncing them.
488+
if (
489+
effectiveSyncFilterMode === 'exclude' &&
490+
excludedTerms.length > 0 &&
491+
matchesSyncFilterTerms(memberEmail, excludedTerms)
492+
) {
370493
continue;
371494
}
372495

373496
// If this member's email is suspended OR not in the active list, deactivate them
374-
const isSuspended = suspendedEmails.has(memberEmail);
497+
const isSuspended = deactivationSuspendedEmails.has(memberEmail);
375498
const isDeleted =
376-
!activeEmails.has(memberEmail) && !suspendedEmails.has(memberEmail);
499+
!deactivationActiveEmails.has(memberEmail) &&
500+
!deactivationSuspendedEmails.has(memberEmail);
377501

378502
if (isSuspended || isDeleted) {
379503
try {
@@ -402,7 +526,7 @@ export class SyncController {
402526
return {
403527
success: true,
404528
totalFound: activeUsers.length,
405-
totalSuspended: suspendedEmails.size,
529+
totalSuspended: filteredSuspendedEmails.size,
406530
...results,
407531
};
408532
}

‎apps/app/src/components/integrations/ManageIntegrationDialog.tsx‎

Lines changed: 80 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { Label } from '@comp/ui/label';
2020
import MultipleSelector from '@comp/ui/multiple-selector';
2121
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select';
2222
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@comp/ui/tabs';
23-
import { Key, Loader2, Settings, Trash2, Unplug, X } from 'lucide-react';
23+
import { Key, Loader2, Settings, Trash2, X } from 'lucide-react';
2424
import Image from 'next/image';
2525
import { useParams } from 'next/navigation';
2626
import { useCallback, useEffect, useRef, useState } from 'react';
@@ -120,6 +120,27 @@ const validateTargetRepos = (
120120
return true;
121121
};
122122

123+
const normalizeMultiSelectValue = (value: unknown): string[] => {
124+
if (Array.isArray(value)) {
125+
return Array.from(
126+
new Set(value.map((item) => String(item).trim()).filter((item) => item.length > 0)),
127+
);
128+
}
129+
130+
if (typeof value === 'string') {
131+
return Array.from(
132+
new Set(
133+
value
134+
.split(/[\n,;]+/)
135+
.map((item) => item.trim())
136+
.filter((item) => item.length > 0),
137+
),
138+
);
139+
}
140+
141+
return [];
142+
};
143+
123144
export function ManageIntegrationDialog({
124145
open,
125146
onOpenChange,
@@ -150,9 +171,7 @@ export function ManageIntegrationDialog({
150171

151172
// Credentials state (for custom auth integrations)
152173
const [credentialFields, setCredentialFields] = useState<CredentialField[]>([]);
153-
const [credentialValues, setCredentialValues] = useState<
154-
Record<string, string | string[]>
155-
>({});
174+
const [credentialValues, setCredentialValues] = useState<Record<string, string | string[]>>({});
156175
const [savingCredentials, setSavingCredentials] = useState(false);
157176
const [authStrategy, setAuthStrategy] = useState<string>('');
158177

@@ -202,7 +221,11 @@ export function ManageIntegrationDialog({
202221
const values: Record<string, string | number | boolean | string[]> = {};
203222
for (const v of vars) {
204223
if (v.currentValue !== undefined) {
205-
values[v.id] = v.currentValue;
224+
if (v.type === 'multi-select') {
225+
values[v.id] = normalizeMultiSelectValue(v.currentValue);
226+
} else {
227+
values[v.id] = v.currentValue;
228+
}
206229
}
207230
}
208231
setVariableValues(values);
@@ -483,10 +506,35 @@ function ConfigurationContent({
483506
);
484507
}
485508

509+
const syncModeVariable = variables.find((variable) => variable.id === 'sync_user_filter_mode');
510+
const rawSyncMode = variableValues.sync_user_filter_mode ?? syncModeVariable?.default ?? 'all';
511+
const effectiveSyncMode = String(rawSyncMode).toLowerCase();
512+
const hasSyncScopedFields = variables.some(
513+
(variable) => variable.id === 'sync_excluded_emails' || variable.id === 'sync_included_emails',
514+
);
515+
516+
const shouldShowVariable = (variable: CheckVariable): boolean => {
517+
if (variable.id === 'sync_excluded_emails') {
518+
return effectiveSyncMode === 'exclude';
519+
}
520+
521+
if (variable.id === 'sync_included_emails') {
522+
return effectiveSyncMode === 'include';
523+
}
524+
525+
return true;
526+
};
527+
486528
const variablesContent = hasVariables && (
487529
<div className="space-y-4">
488530
{!showTabs && <h4 className="text-sm font-medium">Configuration</h4>}
489-
{variables.map((variable) => {
531+
{hasSyncScopedFields && effectiveSyncMode === 'all' && (
532+
<p className="text-xs text-muted-foreground">
533+
Employee sync is set to all users. Include and exclude fields are hidden because they are
534+
not used in this mode.
535+
</p>
536+
)}
537+
{variables.filter(shouldShowVariable).map((variable) => {
490538
const options = dynamicOptions[variable.id] || variable.options || [];
491539
const isLoadingOptions = loadingDynamicOptions[variable.id];
492540

@@ -719,16 +767,16 @@ function ConfigurationContent({
719767
setCredentialValues((prev) => ({ ...prev, [field.id]: val }))
720768
}
721769
>
722-
<SelectTrigger>
723-
<SelectValue placeholder={`Select ${field.label.toLowerCase()}`} />
724-
</SelectTrigger>
725-
<SelectContent>
726-
{field.options.map((opt) => (
727-
<SelectItem key={opt.value} value={opt.value}>
728-
{opt.label}
729-
</SelectItem>
730-
))}
731-
</SelectContent>
770+
<SelectTrigger>
771+
<SelectValue placeholder={`Select ${field.label.toLowerCase()}`} />
772+
</SelectTrigger>
773+
<SelectContent>
774+
{field.options.map((opt) => (
775+
<SelectItem key={opt.value} value={opt.value}>
776+
{opt.label}
777+
</SelectItem>
778+
))}
779+
</SelectContent>
732780
</Select>
733781
);
734782
})()
@@ -896,11 +944,14 @@ function MultiSelectWithBranches({
896944
onLoadOptions: () => void;
897945
}) {
898946
const selectedValues = Array.isArray(value) ? value : [];
947+
const normalizedSelectedValues = selectedValues
948+
.map((item) => String(item).trim())
949+
.filter((item) => item.length > 0);
899950
const hasLoadedRef = useRef(false);
900951

901952
// For target_repos, parse values to extract repos and branches
902953
const isGitHubRepos = variable.id === 'target_repos';
903-
const parsedConfigs = isGitHubRepos ? selectedValues.map(parseRepoBranch) : [];
954+
const parsedConfigs = isGitHubRepos ? normalizedSelectedValues.map(parseRepoBranch) : [];
904955

905956
useEffect(() => {
906957
if (
@@ -936,7 +987,7 @@ function MultiSelectWithBranches({
936987

937988
// Handle branch change for a specific repo
938989
const handleBranchChange = (repo: string, branch: string) => {
939-
const newValues = selectedValues.map((v) => {
990+
const newValues = normalizedSelectedValues.map((v) => {
940991
const parsed = parseRepoBranch(v);
941992
if (parsed.repo === repo) {
942993
// Allow empty string during editing - will default to main on save if empty
@@ -949,12 +1000,15 @@ function MultiSelectWithBranches({
9491000

9501001
// Handle removing a repo
9511002
const handleRemoveRepo = (repo: string) => {
952-
const newValues = selectedValues.filter((v) => parseRepoBranch(v).repo !== repo);
1003+
const newValues = normalizedSelectedValues.filter((v) => parseRepoBranch(v).repo !== repo);
9531004
onChange(newValues);
9541005
};
9551006

9561007
// Get repos from values for display in multi-select
957-
const reposForSelector = isGitHubRepos ? parsedConfigs.map((c) => c.repo) : selectedValues;
1008+
const reposForSelector = isGitHubRepos
1009+
? parsedConfigs.map((c) => c.repo)
1010+
: normalizedSelectedValues;
1011+
const isCreatable = isGitHubRepos || options.length === 0;
9581012

9591013
return (
9601014
<div className="space-y-3">
@@ -966,14 +1020,18 @@ function MultiSelectWithBranches({
9661020
onChange={(selected) => handleRepoSelectionChange(selected.map((s) => s.value))}
9671021
defaultOptions={options.map((o) => ({ value: o.value, label: o.label }))}
9681022
options={options.map((o) => ({ value: o.value, label: o.label }))}
969-
placeholder={`Select ${variable.label.toLowerCase()}...`}
970-
creatable={isGitHubRepos}
1023+
placeholder={variable.placeholder || `Select ${variable.label.toLowerCase()}...`}
1024+
creatable={isCreatable}
9711025
emptyIndicator={
9721026
isLoadingOptions ? (
9731027
<div className="flex items-center gap-2 py-2 px-3 text-sm text-muted-foreground">
9741028
<Loader2 className="h-3 w-3 animate-spin" />
9751029
Loading options...
9761030
</div>
1031+
) : isCreatable ? (
1032+
<p className="text-center text-sm text-muted-foreground">
1033+
Type a value and press Enter
1034+
</p>
9771035
) : (
9781036
<p className="text-center text-sm text-muted-foreground">No options available</p>
9791037
)

0 commit comments

Comments
 (0)