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

View File

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

View File

@ -1,29 +1,62 @@
import { useState } from "react";
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
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 { buildSsoLoginUrl } from "@/ee/security/sso.utils.ts";
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { GoogleIcon } from "@/components/icons/google-icon.tsx";
import { isCloud } from "@/lib/config.ts";
import { LdapLoginModal } from "@/ee/components/ldap-login-modal.tsx";
export default function SsoLogin() {
const { data, isLoading } = useWorkspacePublicDataQuery();
const [ldapModalOpened, setLdapModalOpened] = useState(false);
const [selectedLdapProvider, setSelectedLdapProvider] = useState<IAuthProvider | null>(null);
if (!data?.authProviders || data?.authProviders?.length === 0) {
return null;
}
const handleSsoLogin = (provider: IAuthProvider) => {
window.location.href = buildSsoLoginUrl({
providerId: provider.id,
type: provider.type,
workspaceId: data.id,
});
if (provider.type === SSO_PROVIDER.LDAP) {
// Open modal for LDAP instead of redirecting
setSelectedLdapProvider(provider);
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 (
<>
{selectedLdapProvider && (
<LdapLoginModal
opened={ldapModalOpened}
onClose={() => {
setLdapModalOpened(false);
setSelectedLdapProvider(null);
}}
provider={selectedLdapProvider}
workspaceId={data.id}
/>
)}
{(isCloud() || data.hasLicenseKey) && (
<>
<Stack align="stretch" justify="center" gap="sm">
@ -31,13 +64,7 @@ export default function SsoLogin() {
<div key={provider.id}>
<Button
onClick={() => handleSsoLogin(provider)}
leftSection={
provider.type === SSO_PROVIDER.GOOGLE ? (
<GoogleIcon size={16} />
) : (
<IconLock size={16} />
)
}
leftSection={getProviderIcon(provider)}
variant="default"
fullWidth
>

View File

@ -45,6 +45,7 @@ export function MfaBackupCodeInput({
onChange={(e) => onChange(e.currentTarget.value.toUpperCase())}
error={error}
autoFocus
data-autofocus
maxLength={8}
styles={{
input: {

View File

@ -25,23 +25,30 @@ import { regenerateBackupCodes } from "@/ee/mfa";
import { useForm } from "@mantine/form";
import { zodResolver } from "mantine-form-zod-resolver";
import { z } from "zod";
import useCurrentUser from "@/features/user/hooks/use-current-user";
interface MfaBackupCodesModalProps {
opened: boolean;
onClose: () => void;
}
const formSchema = z.object({
confirmPassword: z.string().min(1, { message: "Password is required" }),
});
export function MfaBackupCodesModal({
opened,
onClose,
}: MfaBackupCodesModalProps) {
const { t } = useTranslation();
const { data: currentUser } = useCurrentUser();
const [backupCodes, setBackupCodes] = useState<string[]>([]);
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({
validate: zodResolver(formSchema),
@ -51,7 +58,7 @@ export function MfaBackupCodesModal({
});
const regenerateMutation = useMutation({
mutationFn: (data: { confirmPassword: string }) =>
mutationFn: (data: { confirmPassword?: string }) =>
regenerateBackupCodes(data),
onSuccess: (data) => {
setBackupCodes(data.backupCodes);
@ -73,8 +80,12 @@ export function MfaBackupCodesModal({
},
});
const handleRegenerate = (values: { confirmPassword: string }) => {
regenerateMutation.mutate(values);
const handleRegenerate = (values: { confirmPassword?: string }) => {
// Only send confirmPassword if it's required (non-SSO users)
const payload = requiresPassword
? { confirmPassword: values.confirmPassword }
: {};
regenerateMutation.mutate(payload);
};
const handleClose = () => {
@ -114,12 +125,16 @@ export function MfaBackupCodesModal({
)}
</Text>
<PasswordInput
label={t("Confirm password")}
placeholder={t("Enter your password")}
variant="filled"
{...form.getInputProps("confirmPassword")}
/>
{requiresPassword && (
<PasswordInput
label={t("Confirm password")}
placeholder={t("Enter your password")}
variant="filled"
{...form.getInputProps("confirmPassword")}
autoFocus
data-autofocus
/>
)}
<Button
type="submit"

View File

@ -97,6 +97,7 @@ export function MfaChallenge() {
length={6}
type="number"
autoFocus
data-autofocus
oneTimeCode
{...form.getInputProps("code")}
error={!!form.errors.code}

View File

@ -15,6 +15,7 @@ import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { disableMfa } from "@/ee/mfa";
import useCurrentUser from "@/features/user/hooks/use-current-user";
interface MfaDisableModalProps {
opened: boolean;
@ -22,16 +23,22 @@ interface MfaDisableModalProps {
onComplete: () => void;
}
const formSchema = z.object({
confirmPassword: z.string().min(1, { message: "Password is required" }),
});
export function MfaDisableModal({
opened,
onClose,
onComplete,
}: MfaDisableModalProps) {
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({
validate: zodResolver(formSchema),
@ -54,8 +61,12 @@ export function MfaDisableModal({
},
});
const handleSubmit = async (values: { confirmPassword: string }) => {
await disableMutation.mutateAsync(values);
const handleSubmit = async (values: { confirmPassword?: string }) => {
// Only send confirmPassword if it's required (non-SSO users)
const payload = requiresPassword
? { confirmPassword: values.confirmPassword }
: {};
await disableMutation.mutateAsync(payload);
};
const handleClose = () => {
@ -85,18 +96,23 @@ export function MfaDisableModal({
</Text>
</Alert>
<Text size="sm">
{t(
"Please enter your password to disable two-factor authentication:",
)}
</Text>
{requiresPassword && (
<>
<Text size="sm">
{t(
"Please enter your password to disable two-factor authentication:",
)}
</Text>
<PasswordInput
label={t("Password")}
placeholder={t("Enter your password")}
{...form.getInputProps("confirmPassword")}
autoFocus
/>
<PasswordInput
label={t("Password")}
placeholder={t("Enter your password")}
{...form.getInputProps("confirmPassword")}
autoFocus
data-autofocus
/>
</>
)}
<Stack gap="sm">
<Button

View File

@ -235,6 +235,7 @@ export function MfaSetupModal({
length={6}
type="number"
autoFocus
data-autofocus
oneTimeCode
{...form.getInputProps("verificationCode")}
styles={{

View File

@ -37,7 +37,7 @@ export async function disableMfa(
}
export async function regenerateBackupCodes(data: {
confirmPassword: string;
confirmPassword?: string;
}): Promise<MfaBackupCodesResponse> {
const req = await api.post<MfaBackupCodesResponse>(
"/mfa/generate-backup-codes",

View File

@ -46,7 +46,7 @@ export interface MfaEnableResponse {
}
export interface MfaDisableRequest {
confirmPassword: string;
confirmPassword?: string;
}
export interface MfaBackupCodesResponse {

View File

@ -1,6 +1,7 @@
import { useAtom } from "jotai";
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 React, { useState } from "react";
import { Button, Text, TagsInput } from "@mantine/core";

View File

@ -1,7 +1,7 @@
import React, { useState } from "react";
import { useDisclosure } from "@mantine/hooks";
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 { SSO_PROVIDER } from "@/ee/security/contants.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 (
<>
<SsoProviderModal opened={opened} onClose={close} provider={provider} />
@ -71,6 +84,13 @@ export default function CreateSsoProvider() {
>
OpenID (OIDC)
</Menu.Item>
<Menu.Item
onClick={handleCreateLDAP}
leftSection={<IconServer size={16} />}
>
LDAP / Active Directory
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>

View File

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

View File

@ -115,15 +115,6 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
{...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">
<div>{t("Group sync")}</div>
<Switch
@ -133,6 +124,15 @@ export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
/>
</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

View File

@ -5,6 +5,7 @@ import { SsoSamlForm } from "@/ee/security/components/sso-saml-form.tsx";
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
import { SsoOIDCForm } from "@/ee/security/components/sso-oidc-form.tsx";
import { SsoGoogleForm } from "@/ee/security/components/sso-google-form.tsx";
import { SsoLDAPForm } from "@/ee/security/components/sso-ldap-form.tsx";
interface SsoModalProps {
opened: boolean;
@ -38,6 +39,10 @@ export default function SsoProviderModal({
{provider.type === SSO_PROVIDER.GOOGLE && (
<SsoGoogleForm provider={provider} onClose={onClose} />
)}
{provider.type === SSO_PROVIDER.LDAP && (
<SsoLDAPForm provider={provider} onClose={onClose} />
)}
</Modal>
);
}

View File

@ -128,15 +128,6 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
{...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">
<div>{t("Group sync")}</div>
<Switch
@ -146,6 +137,15 @@ export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
/>
</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

View File

@ -2,4 +2,5 @@ export enum SSO_PROVIDER {
OIDC = 'oidc',
SAML = 'saml',
GOOGLE = 'google',
LDAP = 'ldap',
}

View File

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

View File

@ -9,6 +9,14 @@ export interface IAuthProvider {
oidcIssuer: string;
oidcClientId: string;
oidcClientSecret: string;
ldapUrl: string;
ldapBindDn: string;
ldapBindPassword: string;
ldapBaseDn: string;
ldapUserSearchFilter: string;
ldapUserAttributes: any;
ldapTlsEnabled: boolean;
ldapTlsCaCert: string;
allowSignup: boolean;
isEnabled: boolean;
groupSync: boolean;

View File

@ -1,16 +1,41 @@
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
import { ICurrentUser } from "@/features/user/types/user.types";
import { focusAtom } from "jotai-optics";
import { ICurrentUser, IUser } from "@/features/user/types/user.types";
import { IWorkspace } from "@/features/workspace/types/workspace.types";
export const currentUserAtom = atomWithStorage<ICurrentUser | null>(
"currentUser",
null,
);
export const userAtom = focusAtom(currentUserAtom, (optic) =>
optic.prop("user"),
export const userAtom = atom(
(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,
});
}
}
);

View File

@ -25,7 +25,7 @@ function LanguageSwitcher() {
const { t, i18n } = useTranslation();
const [user, setUser] = useAtom(userAtom);
const [language, setLanguage] = useState(
user?.locale === "en" ? "en-US" : user.locale,
user?.locale === "en" ? "en-US" : user?.locale,
);
const handleChange = async (value: string) => {

View File

@ -20,6 +20,7 @@ export interface IUser {
deletedAt: Date;
fullPageWidth: boolean; // used for update
pageEditMode: string; // used for update
hasGeneratedPassword?: boolean;
}
export interface ICurrentUser {

View File

@ -5,7 +5,8 @@ import { useState } from "react";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
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 useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";

View File

@ -66,6 +66,7 @@
"jsonwebtoken": "^9.0.2",
"kysely": "^0.28.2",
"kysely-migration-cli": "^0.4.2",
"ldapts": "^7.4.0",
"mime-types": "^2.1.35",
"nanoid": "3.3.11",
"nestjs-kysely": "^1.2.0",

View File

@ -106,6 +106,7 @@ export class AuthService {
await this.userRepo.updateUser(
{
password: newPasswordHash,
hasGeneratedPassword: false,
},
userId,
workspaceId,
@ -186,6 +187,7 @@ export class AuthService {
await this.userRepo.updateUser(
{
password: newPasswordHash,
hasGeneratedPassword: false,
},
user.id,
workspace.id,

View File

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

View File

@ -34,6 +34,7 @@ export class UserRepo {
'createdAt',
'updatedAt',
'deletedAt',
'hasGeneratedPassword',
];
async findById(

View File

@ -5,8 +5,6 @@
import type { ColumnType } from "kysely";
export type AuthProviderType = "google" | "oidc" | "saml";
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>;
@ -63,13 +61,23 @@ export interface AuthProviders {
id: Generated<string>;
isEnabled: 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;
oidcClientId: string | null;
oidcClientSecret: string | null;
oidcIssuer: string | null;
samlCertificate: string | null;
samlUrl: string | null;
type: AuthProviderType;
type: string;
updatedAt: Generated<Timestamp>;
workspaceId: string;
}
@ -276,6 +284,7 @@ export interface Users {
lastActiveAt: Timestamp | null;
lastLoginAt: Timestamp | null;
locale: string | null;
hasGeneratedPassword: Generated<boolean | null>;
name: string | null;
password: string | null;
role: string | null;

78
pnpm-lock.yaml generated
View File

@ -534,6 +534,9 @@ importers:
kysely-migration-cli:
specifier: ^0.4.2
version: 0.4.2
ldapts:
specifier: ^7.4.0
version: 7.4.0
mime-types:
specifier: ^2.1.35
version: 2.1.35
@ -4109,6 +4112,9 @@ packages:
'@tybys/wasm-util@0.9.0':
resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==}
'@types/asn1@0.2.4':
resolution: {integrity: sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA==}
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@ -4833,6 +4839,9 @@ packages:
asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
asn1@0.2.6:
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
async-lock@1.4.1:
resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==}
@ -7032,6 +7041,10 @@ packages:
layout-base@2.0.1:
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:
resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==}
@ -8759,6 +8772,9 @@ packages:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
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:
resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==}
engines: {node: '>=10'}
@ -8998,6 +9014,10 @@ packages:
resolution: {integrity: sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==}
engines: {node: '>=18'}
tr46@5.1.1:
resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
engines: {node: '>=18'}
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
@ -9452,6 +9472,10 @@ packages:
resolution: {integrity: sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==}
engines: {node: '>=18'}
whatwg-url@14.2.0:
resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
engines: {node: '>=18'}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
@ -10321,7 +10345,7 @@ snapshots:
'@babel/traverse': 7.25.9
'@babel/types': 7.26.0
convert-source-map: 2.0.0
debug: 4.3.7
debug: 4.4.0
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@ -10361,7 +10385,7 @@ snapshots:
'@babel/traverse': 7.25.9
'@babel/types': 7.26.0
convert-source-map: 2.0.0
debug: 4.3.4
debug: 4.4.0
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@ -10381,7 +10405,7 @@ snapshots:
'@babel/traverse': 7.27.0
'@babel/types': 7.27.0
convert-source-map: 2.0.0
debug: 4.3.7
debug: 4.4.0
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@ -13945,6 +13969,10 @@ snapshots:
dependencies:
tslib: 2.8.1
'@types/asn1@0.2.4':
dependencies:
'@types/node': 22.13.4
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.26.2
@ -14401,7 +14429,7 @@ snapshots:
'@typescript-eslint/types': 8.17.0
'@typescript-eslint/typescript-estree': 8.17.0(typescript@5.7.2)
'@typescript-eslint/visitor-keys': 8.17.0
debug: 4.3.7
debug: 4.4.0
eslint: 9.15.0(jiti@1.21.0)
optionalDependencies:
typescript: 5.7.2
@ -14414,7 +14442,7 @@ snapshots:
'@typescript-eslint/types': 8.24.1
'@typescript-eslint/typescript-estree': 8.24.1(typescript@5.7.3)
'@typescript-eslint/visitor-keys': 8.24.1
debug: 4.3.7
debug: 4.4.0
eslint: 9.20.1(jiti@1.21.0)
typescript: 5.7.3
transitivePeerDependencies:
@ -14476,7 +14504,7 @@ snapshots:
dependencies:
'@typescript-eslint/types': 8.24.1
'@typescript-eslint/visitor-keys': 8.24.1
debug: 4.3.7
debug: 4.4.0
fast-glob: 3.3.2
is-glob: 4.0.3
minimatch: 9.0.4
@ -14832,6 +14860,10 @@ snapshots:
asap@2.0.6: {}
asn1@0.2.6:
dependencies:
safer-buffer: 2.1.2
async-lock@1.4.1: {}
async@3.2.5: {}
@ -15750,7 +15782,7 @@ snapshots:
detect-port@1.5.1:
dependencies:
address: 1.2.2
debug: 4.3.7
debug: 4.4.0
transitivePeerDependencies:
- supports-color
@ -15871,7 +15903,7 @@ snapshots:
engine.io-client@6.6.2:
dependencies:
'@socket.io/component-emitter': 3.1.0
debug: 4.3.4
debug: 4.3.7
engine.io-parser: 5.2.2
ws: 8.17.1
xmlhttprequest-ssl: 2.1.2
@ -15891,7 +15923,7 @@ snapshots:
base64id: 2.0.0
cookie: 0.7.2
cors: 2.8.5
debug: 4.3.4
debug: 4.3.7
engine.io-parser: 5.2.2
ws: 8.17.1
transitivePeerDependencies:
@ -16795,7 +16827,7 @@ snapshots:
ioredis@4.28.5:
dependencies:
cluster-key-slot: 1.1.2
debug: 4.3.7
debug: 4.4.0
denque: 1.5.1
lodash.defaults: 4.2.0
lodash.flatten: 4.4.0
@ -17544,6 +17576,17 @@ snapshots:
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: {}
less@4.2.0:
@ -19390,7 +19433,7 @@ snapshots:
socket.io-adapter@2.5.4:
dependencies:
debug: 4.3.4
debug: 4.3.7
ws: 8.11.0
transitivePeerDependencies:
- bufferutil
@ -19411,7 +19454,7 @@ snapshots:
socket.io-parser@4.2.4:
dependencies:
'@socket.io/component-emitter': 3.1.0
debug: 4.3.4
debug: 4.3.7
transitivePeerDependencies:
- supports-color
@ -19487,6 +19530,8 @@ snapshots:
streamsearch@1.1.0: {}
strict-event-emitter-types@2.0.0: {}
string-length@4.0.2:
dependencies:
char-regex: 1.0.2
@ -19746,6 +19791,10 @@ snapshots:
dependencies:
punycode: 2.3.1
tr46@5.1.1:
dependencies:
punycode: 2.3.1
tree-kill@1.2.2: {}
truncate-utf8-bytes@1.0.2:
@ -20170,6 +20219,11 @@ snapshots:
tr46: 5.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:
dependencies:
tr46: 0.0.3