feat(EE): LDAP integration (#1515)

* LDAP - WIP

* WIP

* add hasGeneratedPassword

* fix jotai atom

* - don't require password confirmation for MFA is user has auto generated password (LDAP)
- cleanups

* fix

* reorder

* update migration

* update default

* fix type error
This commit is contained in:
Philip Okugbe
2025-09-02 04:59:01 +01:00
committed by GitHub
parent 5968764508
commit dcbb65d799
29 changed files with 723 additions and 90 deletions
@@ -0,0 +1,124 @@
import React, { useState } from "react";
import { Modal, TextInput, PasswordInput, Button, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import { notifications } from "@mantine/notifications";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { IAuthProvider } from "@/ee/security/types/security.types";
import APP_ROUTE from "@/lib/app-route";
import { ldapLogin } from "@/ee/security/services/ldap-auth-service";
const formSchema = z.object({
username: z.string().min(1, { message: "Username is required" }),
password: z.string().min(1, { message: "Password is required" }),
});
interface LdapLoginModalProps {
opened: boolean;
onClose: () => void;
provider: IAuthProvider;
workspaceId: string;
}
export function LdapLoginModal({
opened,
onClose,
provider,
workspaceId,
}: LdapLoginModalProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const form = useForm({
validate: zodResolver(formSchema),
initialValues: {
username: "",
password: "",
},
});
const handleSubmit = async (values: {
username: string;
password: string;
}) => {
setIsLoading(true);
setError(null);
try {
const response = await ldapLogin({
username: values.username,
password: values.password,
providerId: provider.id,
workspaceId,
});
// Handle MFA like the regular login
if (response?.userHasMfa) {
onClose();
navigate(APP_ROUTE.AUTH.MFA_CHALLENGE);
} else if (response?.requiresMfaSetup) {
onClose();
navigate(APP_ROUTE.AUTH.MFA_SETUP_REQUIRED);
} else {
onClose();
navigate(APP_ROUTE.HOME);
}
} catch (err: any) {
setIsLoading(false);
const errorMessage =
err.response?.data?.message || "Authentication failed";
setError(errorMessage);
notifications.show({
message: errorMessage,
color: "red",
});
}
};
const handleClose = () => {
form.reset();
setError(null);
onClose();
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={`LDAP Login - ${provider.name}`}
size="md"
>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
id="ldap-username"
type="text"
label={t("LDAP username")}
placeholder="Enter your LDAP username"
variant="filled"
disabled={isLoading}
data-autofocus
{...form.getInputProps("username")}
/>
<PasswordInput
label={t("LDAP password")}
placeholder={t("Enter your LDAP password")}
variant="filled"
disabled={isLoading}
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="md" loading={isLoading}>
{t("Sign in with LDAP")}
</Button>
</Stack>
</form>
</Modal>
);
}
+40 -13
View File
@@ -1,29 +1,62 @@
import { useState } from "react";
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts"; import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
import { Button, Divider, Stack } from "@mantine/core"; import { Button, Divider, Stack } from "@mantine/core";
import { IconLock } from "@tabler/icons-react"; import { IconLock, IconServer } from "@tabler/icons-react";
import { IAuthProvider } from "@/ee/security/types/security.types.ts"; import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts"; import { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts";
import { SSO_PROVIDER } from "@/ee/security/contants.ts"; import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { GoogleIcon } from "@/components/icons/google-icon.tsx"; import { GoogleIcon } from "@/components/icons/google-icon.tsx";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import { LdapLoginModal } from "@/ee/components/ldap-login-modal.tsx";
export default function SsoLogin() { export default function SsoLogin() {
const { data, isLoading } = useWorkspacePublicDataQuery(); const { data, isLoading } = useWorkspacePublicDataQuery();
const [ldapModalOpened, setLdapModalOpened] = useState(false);
const [selectedLdapProvider, setSelectedLdapProvider] = useState<IAuthProvider | null>(null);
if (!data?.authProviders || data?.authProviders?.length === 0) { if (!data?.authProviders || data?.authProviders?.length === 0) {
return null; return null;
} }
const handleSsoLogin = (provider: IAuthProvider) => { const handleSsoLogin = (provider: IAuthProvider) => {
window.location.href = buildSsoLoginUrl({ if (provider.type === SSO_PROVIDER.LDAP) {
providerId: provider.id, // Open modal for LDAP instead of redirecting
type: provider.type, setSelectedLdapProvider(provider);
workspaceId: data.id, setLdapModalOpened(true);
}); } else {
// Redirect for other SSO providers
window.location.href = buildSsoLoginUrl({
providerId: provider.id,
type: provider.type,
workspaceId: data.id,
});
}
};
const getProviderIcon = (provider: IAuthProvider) => {
if (provider.type === SSO_PROVIDER.GOOGLE) {
return <GoogleIcon size={16} />;
} else if (provider.type === SSO_PROVIDER.LDAP) {
return <IconServer size={16} />;
} else {
return <IconLock size={16} />;
}
}; };
return ( return (
<> <>
{selectedLdapProvider && (
<LdapLoginModal
opened={ldapModalOpened}
onClose={() => {
setLdapModalOpened(false);
setSelectedLdapProvider(null);
}}
provider={selectedLdapProvider}
workspaceId={data.id}
/>
)}
{(isCloud() || data.hasLicenseKey) && ( {(isCloud() || data.hasLicenseKey) && (
<> <>
<Stack align="stretch" justify="center" gap="sm"> <Stack align="stretch" justify="center" gap="sm">
@@ -31,13 +64,7 @@ export default function SsoLogin() {
<div key={provider.id}> <div key={provider.id}>
<Button <Button
onClick={() => handleSsoLogin(provider)} onClick={() => handleSsoLogin(provider)}
leftSection={ leftSection={getProviderIcon(provider)}
provider.type === SSO_PROVIDER.GOOGLE ? (
<GoogleIcon size={16} />
) : (
<IconLock size={16} />
)
}
variant="default" variant="default"
fullWidth fullWidth
> >
@@ -45,6 +45,7 @@ export function MfaBackupCodeInput({
onChange={(e) => onChange(e.currentTarget.value.toUpperCase())} onChange={(e) => onChange(e.currentTarget.value.toUpperCase())}
error={error} error={error}
autoFocus autoFocus
data-autofocus
maxLength={8} maxLength={8}
styles={{ styles={{
input: { input: {
@@ -25,23 +25,30 @@ import { regenerateBackupCodes } from "@/ee/mfa";
import { useForm } from "@mantine/form"; import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver"; import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod"; import { z } from "zod";
import useCurrentUser from "@/features/user/hooks/use-current-user";
interface MfaBackupCodesModalProps { interface MfaBackupCodesModalProps {
opened: boolean; opened: boolean;
onClose: () => void; onClose: () => void;
} }
const formSchema = z.object({
confirmPassword: z.string().min(1, { message: "Password is required" }),
});
export function MfaBackupCodesModal({ export function MfaBackupCodesModal({
opened, opened,
onClose, onClose,
}: MfaBackupCodesModalProps) { }: MfaBackupCodesModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: currentUser } = useCurrentUser();
const [backupCodes, setBackupCodes] = useState<string[]>([]); const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [showNewCodes, setShowNewCodes] = useState(false); const [showNewCodes, setShowNewCodes] = useState(false);
const requiresPassword = !currentUser?.user?.hasGeneratedPassword;
const formSchema = requiresPassword
? z.object({
confirmPassword: z.string().min(1, { message: "Password is required" }),
})
: z.object({
confirmPassword: z.string().optional(),
});
const form = useForm({ const form = useForm({
validate: zodResolver(formSchema), validate: zodResolver(formSchema),
@@ -51,7 +58,7 @@ export function MfaBackupCodesModal({
}); });
const regenerateMutation = useMutation({ const regenerateMutation = useMutation({
mutationFn: (data: { confirmPassword: string }) => mutationFn: (data: { confirmPassword?: string }) =>
regenerateBackupCodes(data), regenerateBackupCodes(data),
onSuccess: (data) => { onSuccess: (data) => {
setBackupCodes(data.backupCodes); setBackupCodes(data.backupCodes);
@@ -73,8 +80,12 @@ export function MfaBackupCodesModal({
}, },
}); });
const handleRegenerate = (values: { confirmPassword: string }) => { const handleRegenerate = (values: { confirmPassword?: string }) => {
regenerateMutation.mutate(values); // Only send confirmPassword if it's required (non-SSO users)
const payload = requiresPassword
? { confirmPassword: values.confirmPassword }
: {};
regenerateMutation.mutate(payload);
}; };
const handleClose = () => { const handleClose = () => {
@@ -114,12 +125,16 @@ export function MfaBackupCodesModal({
)} )}
</Text> </Text>
<PasswordInput {requiresPassword && (
label={t("Confirm password")} <PasswordInput
placeholder={t("Enter your password")} label={t("Confirm password")}
variant="filled" placeholder={t("Enter your password")}
{...form.getInputProps("confirmPassword")} variant="filled"
/> {...form.getInputProps("confirmPassword")}
autoFocus
data-autofocus
/>
)}
<Button <Button
type="submit" type="submit"
@@ -97,6 +97,7 @@ export function MfaChallenge() {
length={6} length={6}
type="number" type="number"
autoFocus autoFocus
data-autofocus
oneTimeCode oneTimeCode
{...form.getInputProps("code")} {...form.getInputProps("code")}
error={!!form.errors.code} error={!!form.errors.code}
@@ -15,6 +15,7 @@ import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { z } from "zod"; import { z } from "zod";
import { disableMfa } from "@/ee/mfa"; import { disableMfa } from "@/ee/mfa";
import useCurrentUser from "@/features/user/hooks/use-current-user";
interface MfaDisableModalProps { interface MfaDisableModalProps {
opened: boolean; opened: boolean;
@@ -22,16 +23,22 @@ interface MfaDisableModalProps {
onComplete: () => void; onComplete: () => void;
} }
const formSchema = z.object({
confirmPassword: z.string().min(1, { message: "Password is required" }),
});
export function MfaDisableModal({ export function MfaDisableModal({
opened, opened,
onClose, onClose,
onComplete, onComplete,
}: MfaDisableModalProps) { }: MfaDisableModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: currentUser } = useCurrentUser();
const requiresPassword = !currentUser?.user?.hasGeneratedPassword;
const formSchema = requiresPassword
? z.object({
confirmPassword: z.string().min(1, { message: "Password is required" }),
})
: z.object({
confirmPassword: z.string().optional(),
});
const form = useForm({ const form = useForm({
validate: zodResolver(formSchema), validate: zodResolver(formSchema),
@@ -54,8 +61,12 @@ export function MfaDisableModal({
}, },
}); });
const handleSubmit = async (values: { confirmPassword: string }) => { const handleSubmit = async (values: { confirmPassword?: string }) => {
await disableMutation.mutateAsync(values); // Only send confirmPassword if it's required (non-SSO users)
const payload = requiresPassword
? { confirmPassword: values.confirmPassword }
: {};
await disableMutation.mutateAsync(payload);
}; };
const handleClose = () => { const handleClose = () => {
@@ -85,18 +96,23 @@ export function MfaDisableModal({
</Text> </Text>
</Alert> </Alert>
<Text size="sm"> {requiresPassword && (
{t( <>
"Please enter your password to disable two-factor authentication:", <Text size="sm">
)} {t(
</Text> "Please enter your password to disable two-factor authentication:",
)}
</Text>
<PasswordInput <PasswordInput
label={t("Password")} label={t("Password")}
placeholder={t("Enter your password")} placeholder={t("Enter your password")}
{...form.getInputProps("confirmPassword")} {...form.getInputProps("confirmPassword")}
autoFocus autoFocus
/> data-autofocus
/>
</>
)}
<Stack gap="sm"> <Stack gap="sm">
<Button <Button
@@ -235,6 +235,7 @@ export function MfaSetupModal({
length={6} length={6}
type="number" type="number"
autoFocus autoFocus
data-autofocus
oneTimeCode oneTimeCode
{...form.getInputProps("verificationCode")} {...form.getInputProps("verificationCode")}
styles={{ styles={{
@@ -37,7 +37,7 @@ export async function disableMfa(
} }
export async function regenerateBackupCodes(data: { export async function regenerateBackupCodes(data: {
confirmPassword: string; confirmPassword?: string;
}): Promise<MfaBackupCodesResponse> { }): Promise<MfaBackupCodesResponse> {
const req = await api.post<MfaBackupCodesResponse>( const req = await api.post<MfaBackupCodesResponse>(
"/mfa/generate-backup-codes", "/mfa/generate-backup-codes",
+1 -1
View File
@@ -46,7 +46,7 @@ export interface MfaEnableResponse {
} }
export interface MfaDisableRequest { export interface MfaDisableRequest {
confirmPassword: string; confirmPassword?: string;
} }
export interface MfaBackupCodesResponse { export interface MfaBackupCodesResponse {
@@ -1,6 +1,7 @@
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import * as z from "zod"; import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form"; import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react"; import React, { useState } from "react";
import { Button, Text, TagsInput } from "@mantine/core"; import { Button, Text, TagsInput } from "@mantine/core";
@@ -1,7 +1,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { Button, Menu, Group } from "@mantine/core"; import { Button, Menu, Group } from "@mantine/core";
import { IconChevronDown, IconLock } from "@tabler/icons-react"; import { IconChevronDown, IconLock, IconServer } from "@tabler/icons-react";
import { useCreateSsoProviderMutation } from "@/ee/security/queries/security-query.ts"; import { useCreateSsoProviderMutation } from "@/ee/security/queries/security-query.ts";
import { SSO_PROVIDER } from "@/ee/security/contants.ts"; import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { IAuthProvider } from "@/ee/security/types/security.types.ts"; import { IAuthProvider } from "@/ee/security/types/security.types.ts";
@@ -40,6 +40,19 @@ export default function CreateSsoProvider() {
} }
}; };
const handleCreateLDAP = async () => {
try {
const newProvider = await createSsoProviderMutation.mutateAsync({
type: SSO_PROVIDER.LDAP,
name: "LDAP",
});
setProvider(newProvider);
open();
} catch (error) {
console.error("Failed to create LDAP provider", error);
}
};
return ( return (
<> <>
<SsoProviderModal opened={opened} onClose={close} provider={provider} /> <SsoProviderModal opened={opened} onClose={close} provider={provider} />
@@ -71,6 +84,13 @@ export default function CreateSsoProvider() {
> >
OpenID (OIDC) OpenID (OIDC)
</Menu.Item> </Menu.Item>
<Menu.Item
onClick={handleCreateLDAP}
leftSection={<IconServer size={16} />}
>
LDAP / Active Directory
</Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
</Group> </Group>
@@ -0,0 +1,228 @@
import React from "react";
import { z } from "zod";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import {
Box,
Button,
Group,
Stack,
Switch,
TextInput,
Textarea,
Text,
Accordion,
} from "@mantine/core";
import classes from "@/ee/security/components/sso.module.css";
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
import { useTranslation } from "react-i18next";
import { useUpdateSsoProviderMutation } from "@/ee/security/queries/security-query.ts";
import { IconInfoCircle } from "@tabler/icons-react";
const ssoSchema = z.object({
name: z.string().min(1, "Display name is required"),
ldapUrl: z.string().url().startsWith("ldap", "Must be an LDAP URL"),
ldapBindDn: z.string().min(1, "Bind DN is required"),
ldapBindPassword: z.string().min(1, "Bind password is required"),
ldapBaseDn: z.string().min(1, "Base DN is required"),
ldapUserSearchFilter: z.string().optional(),
ldapTlsEnabled: z.boolean(),
ldapTlsCaCert: z.string().optional(),
isEnabled: z.boolean(),
allowSignup: z.boolean(),
groupSync: z.boolean(),
});
type SSOFormValues = z.infer<typeof ssoSchema>;
interface SsoFormProps {
provider: IAuthProvider;
onClose?: () => void;
}
export function SsoLDAPForm({ provider, onClose }: SsoFormProps) {
const { t } = useTranslation();
const updateSsoProviderMutation = useUpdateSsoProviderMutation();
const form = useForm<SSOFormValues>({
initialValues: {
name: provider.name || "",
ldapUrl: provider.ldapUrl || "",
ldapBindDn: provider.ldapBindDn || "",
ldapBindPassword: provider.ldapBindPassword || "",
ldapBaseDn: provider.ldapBaseDn || "",
ldapUserSearchFilter:
provider.ldapUserSearchFilter || "(mail={{username}})",
ldapTlsEnabled: provider.ldapTlsEnabled || false,
ldapTlsCaCert: provider.ldapTlsCaCert || "",
isEnabled: provider.isEnabled,
allowSignup: provider.allowSignup,
groupSync: provider.groupSync || false,
},
validate: zodResolver(ssoSchema),
});
const handleSubmit = async (values: SSOFormValues) => {
const ssoData: Partial<IAuthProvider> = {
providerId: provider.id,
};
if (form.isDirty("name")) {
ssoData.name = values.name;
}
if (form.isDirty("ldapUrl")) {
ssoData.ldapUrl = values.ldapUrl;
}
if (form.isDirty("ldapBindDn")) {
ssoData.ldapBindDn = values.ldapBindDn;
}
if (form.isDirty("ldapBindPassword")) {
ssoData.ldapBindPassword = values.ldapBindPassword;
}
if (form.isDirty("ldapBaseDn")) {
ssoData.ldapBaseDn = values.ldapBaseDn;
}
if (form.isDirty("ldapUserSearchFilter")) {
ssoData.ldapUserSearchFilter = values.ldapUserSearchFilter;
}
if (form.isDirty("ldapTlsEnabled")) {
ssoData.ldapTlsEnabled = values.ldapTlsEnabled;
}
if (form.isDirty("ldapTlsCaCert")) {
ssoData.ldapTlsCaCert = values.ldapTlsCaCert;
}
if (form.isDirty("isEnabled")) {
ssoData.isEnabled = values.isEnabled;
}
if (form.isDirty("allowSignup")) {
ssoData.allowSignup = values.allowSignup;
}
if (form.isDirty("groupSync")) {
ssoData.groupSync = values.groupSync;
}
await updateSsoProviderMutation.mutateAsync(ssoData);
form.resetDirty();
onClose();
};
return (
<Box maw={600} mx="auto">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label="Display name"
placeholder="e.g Company LDAP"
data-autofocus
{...form.getInputProps("name")}
/>
<TextInput
label="LDAP Server URL"
description="URL of your LDAP server"
placeholder="ldap://ldap.example.com:389 or ldaps://ldap.example.com:636"
{...form.getInputProps("ldapUrl")}
/>
<TextInput
label="Bind DN"
description="Distinguished Name of the service account for searching"
placeholder="cn=admin,dc=example,dc=com"
{...form.getInputProps("ldapBindDn")}
/>
<TextInput
label="Bind Password"
description="Password for the service account"
type="password"
placeholder="••••••••"
{...form.getInputProps("ldapBindPassword")}
/>
<TextInput
label="Base DN"
description="Base DN where user searches will start"
placeholder="ou=users,dc=example,dc=com"
{...form.getInputProps("ldapBaseDn")}
/>
<TextInput
label="User Search Filter"
description="LDAP filter to find users. Use {{username}} as placeholder"
placeholder="(mail={{username}})"
{...form.getInputProps("ldapUserSearchFilter")}
/>
<Accordion variant="separated">
<Accordion.Item value="advanced">
<Accordion.Control icon={<IconInfoCircle size={20} />}>
Advanced Settings
</Accordion.Control>
<Accordion.Panel>
<Stack>
<Group justify="space-between">
<div>
<Text size="sm">{t("Enable TLS/SSL")}</Text>
<Text size="xs" c="dimmed">
Use secure connection to LDAP server
</Text>
</div>
<Switch
className={classes.switch}
checked={form.values.ldapTlsEnabled}
{...form.getInputProps("ldapTlsEnabled")}
/>
</Group>
{form.values.ldapTlsEnabled && (
<Textarea
label="CA Certificate"
description="PEM-encoded CA certificate for TLS verification (optional)"
placeholder="-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----"
minRows={4}
{...form.getInputProps("ldapTlsCaCert")}
/>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Group justify="space-between">
<div>{t("Group sync")}</div>
<Switch
className={classes.switch}
checked={form.values.groupSync}
{...form.getInputProps("groupSync")}
/>
</Group>
<Group justify="space-between">
<div>{t("Allow signup")}</div>
<Switch
className={classes.switch}
checked={form.values.allowSignup}
{...form.getInputProps("allowSignup")}
/>
</Group>
<Group justify="space-between">
<div>{t("Enabled")}</div>
<Switch
className={classes.switch}
checked={form.values.isEnabled}
{...form.getInputProps("isEnabled")}
/>
</Group>
<Group mt="md" justify="flex-end">
<Button type="submit" disabled={!form.isDirty()}>
{t("Save")}
</Button>
</Group>
</Stack>
</form>
</Box>
);
}
@@ -115,15 +115,6 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
{...form.getInputProps("oidcClientSecret")} {...form.getInputProps("oidcClientSecret")}
/> />
<Group justify="space-between">
<div>{t("Allow signup")}</div>
<Switch
className={classes.switch}
checked={form.values.allowSignup}
{...form.getInputProps("allowSignup")}
/>
</Group>
<Group justify="space-between"> <Group justify="space-between">
<div>{t("Group sync")}</div> <div>{t("Group sync")}</div>
<Switch <Switch
@@ -133,6 +124,15 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
/> />
</Group> </Group>
<Group justify="space-between">
<div>{t("Allow signup")}</div>
<Switch
className={classes.switch}
checked={form.values.allowSignup}
{...form.getInputProps("allowSignup")}
/>
</Group>
<Group justify="space-between"> <Group justify="space-between">
<div>{t("Enabled")}</div> <div>{t("Enabled")}</div>
<Switch <Switch
@@ -5,6 +5,7 @@ import { SsoSamlForm } from "@/ee/security/components/sso-saml-form.tsx";
import { SSO_PROVIDER } from "@/ee/security/contants.ts"; import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { SsoOIDCForm } from "@/ee/security/components/sso-oidc-form.tsx"; import { SsoOIDCForm } from "@/ee/security/components/sso-oidc-form.tsx";
import { SsoGoogleForm } from "@/ee/security/components/sso-google-form.tsx"; import { SsoGoogleForm } from "@/ee/security/components/sso-google-form.tsx";
import { SsoLDAPForm } from "@/ee/security/components/sso-ldap-form.tsx";
interface SsoModalProps { interface SsoModalProps {
opened: boolean; opened: boolean;
@@ -38,6 +39,10 @@ export default function SsoProviderModal({
{provider.type === SSO_PROVIDER.GOOGLE && ( {provider.type === SSO_PROVIDER.GOOGLE && (
<SsoGoogleForm provider={provider} onClose={onClose} /> <SsoGoogleForm provider={provider} onClose={onClose} />
)} )}
{provider.type === SSO_PROVIDER.LDAP && (
<SsoLDAPForm provider={provider} onClose={onClose} />
)}
</Modal> </Modal>
); );
} }
@@ -128,15 +128,6 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
{...form.getInputProps("samlCertificate")} {...form.getInputProps("samlCertificate")}
/> />
<Group justify="space-between">
<div>{t("Allow signup")}</div>
<Switch
className={classes.switch}
checked={form.values.allowSignup}
{...form.getInputProps("allowSignup")}
/>
</Group>
<Group justify="space-between"> <Group justify="space-between">
<div>{t("Group sync")}</div> <div>{t("Group sync")}</div>
<Switch <Switch
@@ -146,6 +137,15 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
/> />
</Group> </Group>
<Group justify="space-between">
<div>{t("Allow signup")}</div>
<Switch
className={classes.switch}
checked={form.values.allowSignup}
{...form.getInputProps("allowSignup")}
/>
</Group>
<Group justify="space-between"> <Group justify="space-between">
<div>{t("Enabled")}</div> <div>{t("Enabled")}</div>
<Switch <Switch
+1
View File
@@ -2,4 +2,5 @@ export enum SSO_PROVIDER {
OIDC = 'oidc', OIDC = 'oidc',
SAML = 'saml', SAML = 'saml',
GOOGLE = 'google', GOOGLE = 'google',
LDAP = 'ldap',
} }
@@ -0,0 +1,23 @@
import api from "@/lib/api-client.ts";
import { ILoginResponse } from "@/features/auth/types/auth.types.ts";
interface ILdapLogin {
username: string;
password: string;
providerId: string;
workspaceId: string;
}
export async function ldapLogin(data: ILdapLogin): Promise<ILoginResponse> {
const requestData = {
username: data.username,
password: data.password,
};
const response = await api.post<ILoginResponse>(
`/sso/ldap/${data.providerId}/login`,
requestData
);
return response.data;
}
@@ -9,6 +9,14 @@ export interface IAuthProvider {
oidcIssuer: string; oidcIssuer: string;
oidcClientId: string; oidcClientId: string;
oidcClientSecret: string; oidcClientSecret: string;
ldapUrl: string;
ldapBindDn: string;
ldapBindPassword: string;
ldapBaseDn: string;
ldapUserSearchFilter: string;
ldapUserAttributes: any;
ldapTlsEnabled: boolean;
ldapTlsCaCert: string;
allowSignup: boolean; allowSignup: boolean;
isEnabled: boolean; isEnabled: boolean;
groupSync: boolean; groupSync: boolean;
@@ -1,16 +1,41 @@
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils"; import { atomWithStorage } from "jotai/utils";
import { ICurrentUser, IUser } from "@/features/user/types/user.types";
import { ICurrentUser } from "@/features/user/types/user.types"; import { IWorkspace } from "@/features/workspace/types/workspace.types";
import { focusAtom } from "jotai-optics";
export const currentUserAtom = atomWithStorage<ICurrentUser | null>( export const currentUserAtom = atomWithStorage<ICurrentUser | null>(
"currentUser", "currentUser",
null, null,
); );
export const userAtom = focusAtom(currentUserAtom, (optic) => export const userAtom = atom(
optic.prop("user"), (get) => {
const currentUser = get(currentUserAtom);
return currentUser?.user ?? null;
},
(get, set, newUser: IUser) => {
const currentUser = get(currentUserAtom);
if (currentUser) {
set(currentUserAtom, {
...currentUser,
user: newUser,
});
}
}
); );
export const workspaceAtom = focusAtom(currentUserAtom, (optic) =>
optic.prop("workspace"), export const workspaceAtom = atom(
(get) => {
const currentUser = get(currentUserAtom);
return currentUser?.workspace ?? null;
},
(get, set, newWorkspace: IWorkspace) => {
const currentUser = get(currentUserAtom);
if (currentUser) {
set(currentUserAtom, {
...currentUser,
workspace: newWorkspace,
});
}
}
); );
@@ -25,7 +25,7 @@ function LanguageSwitcher() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const [user, setUser] = useAtom(userAtom); const [user, setUser] = useAtom(userAtom);
const [language, setLanguage] = useState( const [language, setLanguage] = useState(
user?.locale === "en" ? "en-US" : user.locale, user?.locale === "en" ? "en-US" : user?.locale,
); );
const handleChange = async (value: string) => { const handleChange = async (value: string) => {
@@ -20,6 +20,7 @@ export interface IUser {
deletedAt: Date; deletedAt: Date;
fullPageWidth: boolean; // used for update fullPageWidth: boolean; // used for update
pageEditMode: string; // used for update pageEditMode: string; // used for update
hasGeneratedPassword?: boolean;
} }
export interface ICurrentUser { export interface ICurrentUser {
@@ -5,7 +5,8 @@ import { useState } from "react";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts"; import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
import { TextInput, Button } from "@mantine/core"; import { TextInput, Button } from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form"; import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
+1
View File
@@ -66,6 +66,7 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"kysely": "^0.28.2", "kysely": "^0.28.2",
"kysely-migration-cli": "^0.4.2", "kysely-migration-cli": "^0.4.2",
"ldapts": "^7.4.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"nanoid": "3.3.11", "nanoid": "3.3.11",
"nestjs-kysely": "^1.2.0", "nestjs-kysely": "^1.2.0",
@@ -106,6 +106,7 @@ export class AuthService {
await this.userRepo.updateUser( await this.userRepo.updateUser(
{ {
password: newPasswordHash, password: newPasswordHash,
hasGeneratedPassword: false,
}, },
userId, userId,
workspaceId, workspaceId,
@@ -186,6 +187,7 @@ export class AuthService {
await this.userRepo.updateUser( await this.userRepo.updateUser(
{ {
password: newPasswordHash, password: newPasswordHash,
hasGeneratedPassword: false,
}, },
user.id, user.id,
workspace.id, workspace.id,
@@ -0,0 +1,68 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// switch type to text column since you can't add value to PG types in a transaction
await db.schema
.alterTable('auth_providers')
.alterColumn('type', (col) => col.setDataType('text'))
.execute();
await db.schema.dropType('auth_provider_type').ifExists().execute();
await db.schema
.alterTable('users')
.addColumn('has_generated_password', 'boolean', (col) =>
col.notNull().defaultTo(false).ifNotExists(),
)
.execute();
await db.schema
.alterTable('auth_providers')
.addColumn('ldap_url', 'varchar', (col) => col)
.addColumn('ldap_bind_dn', 'varchar', (col) => col)
.addColumn('ldap_bind_password', 'varchar', (col) => col)
.addColumn('ldap_base_dn', 'varchar', (col) => col)
.addColumn('ldap_user_search_filter', 'varchar', (col) => col)
.addColumn('ldap_user_attributes', 'jsonb', (col) =>
col.defaultTo(sql`'{}'::jsonb`),
)
.addColumn('ldap_tls_enabled', 'boolean', (col) => col.defaultTo(false))
.addColumn('ldap_tls_ca_cert', 'text', (col) => col)
.addColumn('ldap_config', 'jsonb', (col) => col.defaultTo(sql`'{}'::jsonb`))
.addColumn('settings', 'jsonb', (col) => col.defaultTo(sql`'{}'::jsonb`))
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('users')
.dropColumn('has_generated_password')
.execute();
await db.schema
.alterTable('auth_providers')
.dropColumn('ldap_url')
.dropColumn('ldap_bind_dn')
.dropColumn('ldap_bind_password')
.dropColumn('ldap_base_dn')
.dropColumn('ldap_user_search_filter')
.dropColumn('ldap_user_attributes')
.dropColumn('ldap_tls_enabled')
.dropColumn('ldap_tls_ca_cert')
.dropColumn('ldap_config')
.dropColumn('settings')
.execute();
await db.schema
.createType('auth_provider_type')
.asEnum(['saml', 'oidc', 'google'])
.execute();
await db.deleteFrom('auth_providers').where('type', '=', 'ldap').execute();
await sql`
ALTER TABLE auth_providers
ALTER COLUMN type TYPE auth_provider_type
USING type::auth_provider_type
`.execute(db);
}
@@ -34,6 +34,7 @@ export class UserRepo {
'createdAt', 'createdAt',
'updatedAt', 'updatedAt',
'deletedAt', 'deletedAt',
'hasGeneratedPassword',
]; ];
async findById( async findById(
+12 -3
View File
@@ -5,8 +5,6 @@
import type { ColumnType } from "kysely"; import type { ColumnType } from "kysely";
export type AuthProviderType = "google" | "oidc" | "saml";
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U> export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U> ? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>; : ColumnType<T, T | undefined, T>;
@@ -63,13 +61,23 @@ export interface AuthProviders {
id: Generated<string>; id: Generated<string>;
isEnabled: Generated<boolean>; isEnabled: Generated<boolean>;
groupSync: Generated<boolean>; groupSync: Generated<boolean>;
ldapBaseDn: string | null;
ldapBindDn: string | null;
ldapBindPassword: string | null;
ldapTlsCaCert: string | null;
ldapTlsEnabled: Generated<boolean | null>;
ldapUrl: string | null;
ldapUserAttributes: Json | null;
ldapUserSearchFilter: string | null;
ldapConfig: Json | null;
settings: Json | null;
name: string; name: string;
oidcClientId: string | null; oidcClientId: string | null;
oidcClientSecret: string | null; oidcClientSecret: string | null;
oidcIssuer: string | null; oidcIssuer: string | null;
samlCertificate: string | null; samlCertificate: string | null;
samlUrl: string | null; samlUrl: string | null;
type: AuthProviderType; type: string;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
workspaceId: string; workspaceId: string;
} }
@@ -276,6 +284,7 @@ export interface Users {
lastActiveAt: Timestamp | null; lastActiveAt: Timestamp | null;
lastLoginAt: Timestamp | null; lastLoginAt: Timestamp | null;
locale: string | null; locale: string | null;
hasGeneratedPassword: Generated<boolean | null>;
name: string | null; name: string | null;
password: string | null; password: string | null;
role: string | null; role: string | null;
+66 -12
View File
@@ -534,6 +534,9 @@ importers:
kysely-migration-cli: kysely-migration-cli:
specifier: ^0.4.2 specifier: ^0.4.2
version: 0.4.2 version: 0.4.2
ldapts:
specifier: ^7.4.0
version: 7.4.0
mime-types: mime-types:
specifier: ^2.1.35 specifier: ^2.1.35
version: 2.1.35 version: 2.1.35
@@ -4109,6 +4112,9 @@ packages:
'@tybys/wasm-util@0.9.0': '@tybys/wasm-util@0.9.0':
resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==}
'@types/asn1@0.2.4':
resolution: {integrity: sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA==}
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -4833,6 +4839,9 @@ packages:
asap@2.0.6: asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
asn1@0.2.6:
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
async-lock@1.4.1: async-lock@1.4.1:
resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==}
@@ -7032,6 +7041,10 @@ packages:
layout-base@2.0.1: layout-base@2.0.1:
resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==}
ldapts@7.4.0:
resolution: {integrity: sha512-QLgx2pLvxMXY1nCc85Fx+cwVJDvC0sQ3l4CJZSl1FJ/iV8Ypfl6m+5xz4lm1lhoXcUlvhPqxEoyIj/8LR6ut+A==}
engines: {node: '>=18'}
leac@0.6.0: leac@0.6.0:
resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==}
@@ -8759,6 +8772,9 @@ packages:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
strict-event-emitter-types@2.0.0:
resolution: {integrity: sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==}
string-length@4.0.2: string-length@4.0.2:
resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -8998,6 +9014,10 @@ packages:
resolution: {integrity: sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==} resolution: {integrity: sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==}
engines: {node: '>=18'} engines: {node: '>=18'}
tr46@5.1.1:
resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
engines: {node: '>=18'}
tree-kill@1.2.2: tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true hasBin: true
@@ -9452,6 +9472,10 @@ packages:
resolution: {integrity: sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==} resolution: {integrity: sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==}
engines: {node: '>=18'} engines: {node: '>=18'}
whatwg-url@14.2.0:
resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
engines: {node: '>=18'}
whatwg-url@5.0.0: whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
@@ -10321,7 +10345,7 @@ snapshots:
'@babel/traverse': 7.25.9 '@babel/traverse': 7.25.9
'@babel/types': 7.26.0 '@babel/types': 7.26.0
convert-source-map: 2.0.0 convert-source-map: 2.0.0
debug: 4.3.7 debug: 4.4.0
gensync: 1.0.0-beta.2 gensync: 1.0.0-beta.2
json5: 2.2.3 json5: 2.2.3
semver: 6.3.1 semver: 6.3.1
@@ -10361,7 +10385,7 @@ snapshots:
'@babel/traverse': 7.25.9 '@babel/traverse': 7.25.9
'@babel/types': 7.26.0 '@babel/types': 7.26.0
convert-source-map: 2.0.0 convert-source-map: 2.0.0
debug: 4.3.4 debug: 4.4.0
gensync: 1.0.0-beta.2 gensync: 1.0.0-beta.2
json5: 2.2.3 json5: 2.2.3
semver: 6.3.1 semver: 6.3.1
@@ -10381,7 +10405,7 @@ snapshots:
'@babel/traverse': 7.27.0 '@babel/traverse': 7.27.0
'@babel/types': 7.27.0 '@babel/types': 7.27.0
convert-source-map: 2.0.0 convert-source-map: 2.0.0
debug: 4.3.7 debug: 4.4.0
gensync: 1.0.0-beta.2 gensync: 1.0.0-beta.2
json5: 2.2.3 json5: 2.2.3
semver: 6.3.1 semver: 6.3.1
@@ -13945,6 +13969,10 @@ snapshots:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
'@types/asn1@0.2.4':
dependencies:
'@types/node': 22.13.4
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
dependencies: dependencies:
'@babel/parser': 7.26.2 '@babel/parser': 7.26.2
@@ -14401,7 +14429,7 @@ snapshots:
'@typescript-eslint/types': 8.17.0 '@typescript-eslint/types': 8.17.0
'@typescript-eslint/typescript-estree': 8.17.0(typescript@5.7.2) '@typescript-eslint/typescript-estree': 8.17.0(typescript@5.7.2)
'@typescript-eslint/visitor-keys': 8.17.0 '@typescript-eslint/visitor-keys': 8.17.0
debug: 4.3.7 debug: 4.4.0
eslint: 9.15.0(jiti@1.21.0) eslint: 9.15.0(jiti@1.21.0)
optionalDependencies: optionalDependencies:
typescript: 5.7.2 typescript: 5.7.2
@@ -14414,7 +14442,7 @@ snapshots:
'@typescript-eslint/types': 8.24.1 '@typescript-eslint/types': 8.24.1
'@typescript-eslint/typescript-estree': 8.24.1(typescript@5.7.3) '@typescript-eslint/typescript-estree': 8.24.1(typescript@5.7.3)
'@typescript-eslint/visitor-keys': 8.24.1 '@typescript-eslint/visitor-keys': 8.24.1
debug: 4.3.7 debug: 4.4.0
eslint: 9.20.1(jiti@1.21.0) eslint: 9.20.1(jiti@1.21.0)
typescript: 5.7.3 typescript: 5.7.3
transitivePeerDependencies: transitivePeerDependencies:
@@ -14476,7 +14504,7 @@ snapshots:
dependencies: dependencies:
'@typescript-eslint/types': 8.24.1 '@typescript-eslint/types': 8.24.1
'@typescript-eslint/visitor-keys': 8.24.1 '@typescript-eslint/visitor-keys': 8.24.1
debug: 4.3.7 debug: 4.4.0
fast-glob: 3.3.2 fast-glob: 3.3.2
is-glob: 4.0.3 is-glob: 4.0.3
minimatch: 9.0.4 minimatch: 9.0.4
@@ -14832,6 +14860,10 @@ snapshots:
asap@2.0.6: {} asap@2.0.6: {}
asn1@0.2.6:
dependencies:
safer-buffer: 2.1.2
async-lock@1.4.1: {} async-lock@1.4.1: {}
async@3.2.5: {} async@3.2.5: {}
@@ -15750,7 +15782,7 @@ snapshots:
detect-port@1.5.1: detect-port@1.5.1:
dependencies: dependencies:
address: 1.2.2 address: 1.2.2
debug: 4.3.7 debug: 4.4.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -15871,7 +15903,7 @@ snapshots:
engine.io-client@6.6.2: engine.io-client@6.6.2:
dependencies: dependencies:
'@socket.io/component-emitter': 3.1.0 '@socket.io/component-emitter': 3.1.0
debug: 4.3.4 debug: 4.3.7
engine.io-parser: 5.2.2 engine.io-parser: 5.2.2
ws: 8.17.1 ws: 8.17.1
xmlhttprequest-ssl: 2.1.2 xmlhttprequest-ssl: 2.1.2
@@ -15891,7 +15923,7 @@ snapshots:
base64id: 2.0.0 base64id: 2.0.0
cookie: 0.7.2 cookie: 0.7.2
cors: 2.8.5 cors: 2.8.5
debug: 4.3.4 debug: 4.3.7
engine.io-parser: 5.2.2 engine.io-parser: 5.2.2
ws: 8.17.1 ws: 8.17.1
transitivePeerDependencies: transitivePeerDependencies:
@@ -16795,7 +16827,7 @@ snapshots:
ioredis@4.28.5: ioredis@4.28.5:
dependencies: dependencies:
cluster-key-slot: 1.1.2 cluster-key-slot: 1.1.2
debug: 4.3.7 debug: 4.4.0
denque: 1.5.1 denque: 1.5.1
lodash.defaults: 4.2.0 lodash.defaults: 4.2.0
lodash.flatten: 4.4.0 lodash.flatten: 4.4.0
@@ -17544,6 +17576,17 @@ snapshots:
layout-base@2.0.1: {} layout-base@2.0.1: {}
ldapts@7.4.0:
dependencies:
'@types/asn1': 0.2.4
asn1: 0.2.6
debug: 4.4.0
strict-event-emitter-types: 2.0.0
uuid: 11.1.0
whatwg-url: 14.2.0
transitivePeerDependencies:
- supports-color
leac@0.6.0: {} leac@0.6.0: {}
less@4.2.0: less@4.2.0:
@@ -19390,7 +19433,7 @@ snapshots:
socket.io-adapter@2.5.4: socket.io-adapter@2.5.4:
dependencies: dependencies:
debug: 4.3.4 debug: 4.3.7
ws: 8.11.0 ws: 8.11.0
transitivePeerDependencies: transitivePeerDependencies:
- bufferutil - bufferutil
@@ -19411,7 +19454,7 @@ snapshots:
socket.io-parser@4.2.4: socket.io-parser@4.2.4:
dependencies: dependencies:
'@socket.io/component-emitter': 3.1.0 '@socket.io/component-emitter': 3.1.0
debug: 4.3.4 debug: 4.3.7
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -19487,6 +19530,8 @@ snapshots:
streamsearch@1.1.0: {} streamsearch@1.1.0: {}
strict-event-emitter-types@2.0.0: {}
string-length@4.0.2: string-length@4.0.2:
dependencies: dependencies:
char-regex: 1.0.2 char-regex: 1.0.2
@@ -19746,6 +19791,10 @@ snapshots:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
tr46@5.1.1:
dependencies:
punycode: 2.3.1
tree-kill@1.2.2: {} tree-kill@1.2.2: {}
truncate-utf8-bytes@1.0.2: truncate-utf8-bytes@1.0.2:
@@ -20170,6 +20219,11 @@ snapshots:
tr46: 5.0.0 tr46: 5.0.0
webidl-conversions: 7.0.0 webidl-conversions: 7.0.0
whatwg-url@14.2.0:
dependencies:
tr46: 5.1.1
webidl-conversions: 7.0.0
whatwg-url@5.0.0: whatwg-url@5.0.0:
dependencies: dependencies:
tr46: 0.0.3 tr46: 0.0.3