mirror of
https://github.com/docmost/docmost.git
synced 2025-11-15 12:11:09 +10:00
feat: cloud and ee (#805)
* stripe init git submodules for enterprise modules * * Cloud billing UI - WIP * Proxy websockets in dev mode * Separate workspace login and creation for cloud * Other fixes * feat: billing (cloud) * * add domain service * prepare links from workspace hostname * WIP * Add exchange token generation * Validate JWT token type during verification * domain service * add SkipTransform decorator * * updates (server) * add new packages * new sso migration file * WIP * Fix hostname generation * WIP * WIP * Reduce input error font-size * set max password length * jwt package * license page - WIP * * License management UI * Move license key store to db * add reflector * SSO enforcement * * Add default plan * Add usePlan hook * * Fix auth container margin in mobile * Redirect login and home to select page in cloud * update .gitignore * Default to yearly * * Trial messaging * Handle ended trials * Don't set to readonly on collab disconnect (Cloud) * Refine trial (UI) * Fix bug caused by using jotai optics atom in AppHeader component * configurable database maximum pool * Close SSO form on save * wip * sync * Only show sign-in in cloud * exclude base api part from workspaceId check * close db connection beforeApplicationShutdown * Add health/live endpoint * clear cookie on hostname change * reset currentUser atom * Change text * return 401 if workspace does not match * feat: show user workspace list in cloud login page * sync * Add home path * Prefetch to speed up queries * * Add robots.txt * Disallow login and forgot password routes * wildcard user-agent * Fix space query cache * fix * fix * use space uuid for recent pages * prefetch billing plans * enhance license page * sync
This commit is contained in:
88
apps/client/src/ee/security/components/allowed-domains.tsx
Normal file
88
apps/client/src/ee/security/components/allowed-domains.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { useAtom } from "jotai";
|
||||
import * as z from "zod";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import React, { useState } from "react";
|
||||
import { Button, Text, TagsInput } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||
import { IWorkspace } from "@/features/workspace/types/workspace.types.ts";
|
||||
|
||||
const formSchema = z.object({
|
||||
emailDomains: z.array(z.string()),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
export default function AllowedDomains() {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const [, setDomains] = useState<string[]>([]);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zodResolver(formSchema),
|
||||
initialValues: {
|
||||
emailDomains: workspace?.emailDomains || [],
|
||||
},
|
||||
});
|
||||
|
||||
async function handleSubmit(data: Partial<IWorkspace>) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const updatedWorkspace = await updateWorkspace({
|
||||
emailDomains: data.emailDomains,
|
||||
});
|
||||
setWorkspace(updatedWorkspace);
|
||||
|
||||
notifications.show({
|
||||
message: t("Updated successfully"),
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
notifications.show({
|
||||
message: err.response.data.message,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
|
||||
form.resetDirty();
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Text size="md">Allowed email domains</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Only users with email addresses from these domains can signup via SSO.
|
||||
</Text>
|
||||
</div>
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<TagsInput
|
||||
mt="sm"
|
||||
description={t(
|
||||
"Enter valid domain names separated by comma or space",
|
||||
)}
|
||||
placeholder={t("e.g acme.com")}
|
||||
variant="filled"
|
||||
splitChars={[",", " "]}
|
||||
maxDropdownHeight={0}
|
||||
maxTags={20}
|
||||
onChange={setDomains}
|
||||
{...form.getInputProps("emailDomains")}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
mt="sm"
|
||||
disabled={!form.isDirty()}
|
||||
loading={isLoading}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
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 { 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";
|
||||
import SsoProviderModal from "@/ee/security/components/sso-provider-modal.tsx";
|
||||
import { OpenIdIcon } from "@/components/icons/openid-icon.tsx";
|
||||
|
||||
export default function CreateSsoProvider() {
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [provider, setProvider] = useState<IAuthProvider | null>(null);
|
||||
|
||||
const createSsoProviderMutation = useCreateSsoProviderMutation();
|
||||
|
||||
const handleCreateSAML = async () => {
|
||||
try {
|
||||
const newProvider = await createSsoProviderMutation.mutateAsync({
|
||||
type: SSO_PROVIDER.SAML,
|
||||
name: "SAML",
|
||||
});
|
||||
setProvider(newProvider);
|
||||
open();
|
||||
} catch (error) {
|
||||
console.error("Failed to create SAML provider", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateOIDC = async () => {
|
||||
try {
|
||||
const newProvider = await createSsoProviderMutation.mutateAsync({
|
||||
type: SSO_PROVIDER.OIDC,
|
||||
name: "OIDC",
|
||||
});
|
||||
setProvider(newProvider);
|
||||
open();
|
||||
} catch (error) {
|
||||
console.error("Failed to create OIDC provider", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SsoProviderModal opened={opened} onClose={close} provider={provider} />
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Menu
|
||||
transitionProps={{ transition: "pop-top-right" }}
|
||||
position="bottom"
|
||||
width={220}
|
||||
withinPortal
|
||||
>
|
||||
<Menu.Target>
|
||||
<Button rightSection={<IconChevronDown size={16} />} pr={12}>
|
||||
Create SSO
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={handleCreateSAML}
|
||||
leftSection={<IconLock size={16} />}
|
||||
>
|
||||
SAML
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
onClick={handleCreateOIDC}
|
||||
leftSection={<OpenIdIcon size={16} />}
|
||||
>
|
||||
OpenID (OIDC)
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
61
apps/client/src/ee/security/components/enforce-sso.tsx
Normal file
61
apps/client/src/ee/security/components/enforce-sso.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { Group, Text, Switch, MantineSize } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
||||
export default function EnforceSso() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Text size="md">{t("Enforce SSO")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"Once enforced, members will not able able to login with email and password.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<EnforceSsoToggle />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
interface EnforceSsoToggleProps {
|
||||
size?: MantineSize;
|
||||
label?: string;
|
||||
}
|
||||
export function EnforceSsoToggle({ size, label }: EnforceSsoToggleProps) {
|
||||
const { t } = useTranslation();
|
||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||
const [checked, setChecked] = useState(workspace?.enforceSso);
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
try {
|
||||
const updatedWorkspace = await updateWorkspace({ enforceSso: value });
|
||||
setChecked(value);
|
||||
setWorkspace(updatedWorkspace);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message: err?.response?.data?.message,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Switch
|
||||
size={size}
|
||||
label={label}
|
||||
labelPosition="left"
|
||||
defaultChecked={checked}
|
||||
onChange={handleChange}
|
||||
aria-label={t("Toggle sso enforcement")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
91
apps/client/src/ee/security/components/sso-google-form.tsx
Normal file
91
apps/client/src/ee/security/components/sso-google-form.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import React from "react";
|
||||
import { z } from "zod";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import { Box, Button, Group, Stack, Switch, TextInput } 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";
|
||||
|
||||
const ssoSchema = z.object({
|
||||
name: z.string().min(1, "Provider name is required"),
|
||||
isEnabled: z.boolean(),
|
||||
allowSignup: z.boolean(),
|
||||
});
|
||||
|
||||
type SSOFormValues = z.infer<typeof ssoSchema>;
|
||||
|
||||
interface SsoFormProps {
|
||||
provider: IAuthProvider;
|
||||
onClose?: () => void;
|
||||
}
|
||||
export function SsoGoogleForm({ provider, onClose }: SsoFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const updateSsoProviderMutation = useUpdateSsoProviderMutation();
|
||||
|
||||
const form = useForm<SSOFormValues>({
|
||||
initialValues: {
|
||||
name: provider.name || "",
|
||||
isEnabled: provider.isEnabled,
|
||||
allowSignup: provider.allowSignup,
|
||||
},
|
||||
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("isEnabled")) {
|
||||
ssoData.isEnabled = values.isEnabled;
|
||||
}
|
||||
if (form.isDirty("allowSignup")) {
|
||||
ssoData.allowSignup = values.allowSignup;
|
||||
}
|
||||
|
||||
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 Okta SSO"
|
||||
readOnly
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
140
apps/client/src/ee/security/components/sso-oidc-form.tsx
Normal file
140
apps/client/src/ee/security/components/sso-oidc-form.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
import React from "react";
|
||||
import { z } from "zod";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import { Box, Button, Group, Stack, Switch, TextInput } from "@mantine/core";
|
||||
import { buildCallbackUrl } from "@/ee/security/sso.utils.ts";
|
||||
import classes from "@/ee/security/components/sso.module.css";
|
||||
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
||||
import CopyTextButton from "@/components/common/copy.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUpdateSsoProviderMutation } from "@/ee/security/queries/security-query.ts";
|
||||
|
||||
const ssoSchema = z.object({
|
||||
name: z.string().min(1, "Display name is required"),
|
||||
oidcIssuer: z.string().url(),
|
||||
oidcClientId: z.string().min(1, "Client id is required"),
|
||||
oidcClientSecret: z.string().min(1, "Client secret is required"),
|
||||
isEnabled: z.boolean(),
|
||||
allowSignup: z.boolean(),
|
||||
});
|
||||
|
||||
type SSOFormValues = z.infer<typeof ssoSchema>;
|
||||
|
||||
interface SsoFormProps {
|
||||
provider: IAuthProvider;
|
||||
onClose?: () => void;
|
||||
}
|
||||
export function SsoOIDCForm({ provider, onClose }: SsoFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const updateSsoProviderMutation = useUpdateSsoProviderMutation();
|
||||
|
||||
const form = useForm<SSOFormValues>({
|
||||
initialValues: {
|
||||
name: provider.name || "",
|
||||
oidcIssuer: provider.oidcIssuer || "",
|
||||
oidcClientId: provider.oidcClientId || "",
|
||||
oidcClientSecret: provider.oidcClientSecret || "",
|
||||
isEnabled: provider.isEnabled,
|
||||
allowSignup: provider.allowSignup,
|
||||
},
|
||||
validate: zodResolver(ssoSchema),
|
||||
});
|
||||
|
||||
const callbackUrl = buildCallbackUrl({
|
||||
providerId: provider.id,
|
||||
type: provider.type,
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: SSOFormValues) => {
|
||||
const ssoData: Partial<IAuthProvider> = {
|
||||
providerId: provider.id,
|
||||
};
|
||||
if (form.isDirty("name")) {
|
||||
ssoData.name = values.name;
|
||||
}
|
||||
if (form.isDirty("oidcIssuer")) {
|
||||
ssoData.oidcIssuer = values.oidcIssuer;
|
||||
}
|
||||
if (form.isDirty("oidcClientId")) {
|
||||
ssoData.oidcClientId = values.oidcClientId;
|
||||
}
|
||||
if (form.isDirty("oidcClientSecret")) {
|
||||
ssoData.oidcClientSecret = values.oidcClientSecret;
|
||||
}
|
||||
if (form.isDirty("isEnabled")) {
|
||||
ssoData.isEnabled = values.isEnabled;
|
||||
}
|
||||
if (form.isDirty("allowSignup")) {
|
||||
ssoData.allowSignup = values.allowSignup;
|
||||
}
|
||||
|
||||
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 Google SSO"
|
||||
data-autofocus
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Callback URL"
|
||||
variant="filled"
|
||||
value={callbackUrl}
|
||||
pointer
|
||||
readOnly
|
||||
rightSection={<CopyTextButton text={callbackUrl} />}
|
||||
/>
|
||||
<TextInput
|
||||
label="Issuer URL"
|
||||
description="Enter your OIDC issuer URL"
|
||||
placeholder="e.g https://accounts.google.com/"
|
||||
{...form.getInputProps("oidcIssuer")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Client ID"
|
||||
description="Enter your OIDC ClientId"
|
||||
placeholder="e.g 292085223830.apps.googleusercontent.com"
|
||||
{...form.getInputProps("oidcClientId")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Client Secret"
|
||||
description="Enter your OIDC Client Secret"
|
||||
placeholder="e.g OCSPX-zVCkotEPGRnJA1XKUrbgjlf7PQQ-"
|
||||
{...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("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>
|
||||
);
|
||||
}
|
||||
186
apps/client/src/ee/security/components/sso-provider-list.tsx
Normal file
186
apps/client/src/ee/security/components/sso-provider-list.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
useDeleteSsoProviderMutation,
|
||||
useGetSsoProviders,
|
||||
} from "@/ee/security/queries/security-query.ts";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Card,
|
||||
Group,
|
||||
Menu,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconCheck,
|
||||
IconDots,
|
||||
IconLock,
|
||||
IconPencil,
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SsoProviderModal from "@/ee/security/components/sso-provider-modal.tsx";
|
||||
import { SSO_PROVIDER } from "@/ee/security/contants.ts";
|
||||
import { GoogleIcon } from "@/components/icons/google-icon.tsx";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import RoleSelectMenu from "@/components/ui/role-select-menu.tsx";
|
||||
import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";
|
||||
|
||||
export default function SsoProviderList() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useGetSsoProviders();
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const deleteSsoProviderMutation = useDeleteSsoProviderMutation();
|
||||
const [editProvider, setEditProvider] = useState<IAuthProvider | null>(null);
|
||||
|
||||
if (isLoading || !data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (data?.length === 0) {
|
||||
return <Text c="dimmed">{t("No SSO providers found.")}</Text>;
|
||||
}
|
||||
|
||||
const handleEdit = (provider: IAuthProvider) => {
|
||||
setEditProvider(provider);
|
||||
open();
|
||||
};
|
||||
|
||||
const openDeleteModal = (providerId: string) =>
|
||||
modals.openConfirmModal({
|
||||
title: t("Delete SSO provider"),
|
||||
centered: true,
|
||||
children: (
|
||||
<Text size="sm">
|
||||
{t("Are you sure you want to delete this SSO provider?")}
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: t("Delete"), cancel: t("Don't") },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () => deleteSsoProviderMutation.mutateAsync(providerId),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card shadow="sm" radius="sm">
|
||||
<Table.ScrollContainer minWidth={500}>
|
||||
<Table verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Name")}</Table.Th>
|
||||
<Table.Th>{t("Type")}</Table.Th>
|
||||
<Table.Th>{t("Status")}</Table.Th>
|
||||
<Table.Th>{t("Allow signup")}</Table.Th>
|
||||
<Table.Th>{t("Action")}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{data
|
||||
.sort((a, b) => {
|
||||
const enabledDiff = Number(b.isEnabled) - Number(a.isEnabled);
|
||||
if (enabledDiff !== 0) return enabledDiff;
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.map((provider: IAuthProvider, index) => (
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{provider.type === SSO_PROVIDER.GOOGLE ? (
|
||||
<GoogleIcon size={16} />
|
||||
) : (
|
||||
<IconLock size={16} />
|
||||
)}
|
||||
<div>
|
||||
<Text fz="sm" fw={500}>
|
||||
{provider.name}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={"gray"} variant="light">
|
||||
{provider.type.toUpperCase()}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={provider.isEnabled ? "blue" : "gray"}
|
||||
variant="light"
|
||||
>
|
||||
{provider.isEnabled ? "Active" : "InActive"}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{provider.allowSignup ? (
|
||||
<ThemeIcon variant="light" size={24} radius="xl">
|
||||
<IconCheck size={16} />
|
||||
</ThemeIcon>
|
||||
) : (
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
size={24}
|
||||
radius="xl"
|
||||
>
|
||||
<IconX size={16} />
|
||||
</ThemeIcon>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => handleEdit(provider)}
|
||||
>
|
||||
<IconPencil size={16} />
|
||||
</ActionIcon>
|
||||
<Menu
|
||||
transitionProps={{ transition: "pop" }}
|
||||
withArrow
|
||||
position="bottom-end"
|
||||
withinPortal
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray">
|
||||
<IconDots size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={() => handleEdit(provider)}
|
||||
leftSection={<IconPencil size={16} />}
|
||||
>
|
||||
{t("Edit")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => openDeleteModal(provider.id)}
|
||||
leftSection={<IconTrash size={16} />}
|
||||
color="red"
|
||||
disabled={provider.type === SSO_PROVIDER.GOOGLE}
|
||||
>
|
||||
{t("Delete")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
</Card>
|
||||
|
||||
<SsoProviderModal
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
provider={editProvider}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
import { Modal } from "@mantine/core";
|
||||
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
||||
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";
|
||||
|
||||
interface SsoModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
provider: IAuthProvider | null;
|
||||
}
|
||||
|
||||
export default function SsoProviderModal({
|
||||
opened,
|
||||
onClose,
|
||||
provider,
|
||||
}: SsoModalProps) {
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
title={`${provider.type.toUpperCase()} Configuration`}
|
||||
onClose={onClose}
|
||||
>
|
||||
{provider.type === SSO_PROVIDER.SAML && (
|
||||
<SsoSamlForm provider={provider} onClose={onClose} />
|
||||
)}
|
||||
|
||||
{provider.type === SSO_PROVIDER.OIDC && (
|
||||
<SsoOIDCForm provider={provider} onClose={onClose} />
|
||||
)}
|
||||
|
||||
{provider.type === SSO_PROVIDER.GOOGLE && (
|
||||
<SsoGoogleForm provider={provider} onClose={onClose} />
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
153
apps/client/src/ee/security/components/sso-saml-form.tsx
Normal file
153
apps/client/src/ee/security/components/sso-saml-form.tsx
Normal file
@ -0,0 +1,153 @@
|
||||
import React from "react";
|
||||
import { z } from "zod";
|
||||
import { useForm, zodResolver } from "@mantine/form";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Switch,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
buildCallbackUrl,
|
||||
buildSamlEntityId,
|
||||
} from "@/ee/security/sso.utils.ts";
|
||||
import classes from "@/ee/security/components/sso.module.css";
|
||||
import { IAuthProvider } from "@/ee/security/types/security.types.ts";
|
||||
import CopyTextButton from "@/components/common/copy.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUpdateSsoProviderMutation } from "@/ee/security/queries/security-query.ts";
|
||||
|
||||
const ssoSchema = z.object({
|
||||
name: z.string().min(1, "Display name is required"),
|
||||
samlUrl: z.string().url(),
|
||||
samlCertificate: z.string().min(1, "SAML Idp Certificate is required"),
|
||||
isEnabled: z.boolean(),
|
||||
allowSignup: z.boolean(),
|
||||
});
|
||||
|
||||
type SSOFormValues = z.infer<typeof ssoSchema>;
|
||||
|
||||
interface SsoFormProps {
|
||||
provider: IAuthProvider;
|
||||
onClose?: () => void;
|
||||
}
|
||||
export function SsoSamlForm({ provider, onClose }: SsoFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const updateSsoProviderMutation = useUpdateSsoProviderMutation();
|
||||
|
||||
const form = useForm<SSOFormValues>({
|
||||
initialValues: {
|
||||
name: provider.name || "",
|
||||
samlUrl: provider.samlUrl || "",
|
||||
samlCertificate: provider.samlCertificate || "",
|
||||
isEnabled: provider.isEnabled,
|
||||
allowSignup: provider.allowSignup,
|
||||
},
|
||||
validate: zodResolver(ssoSchema),
|
||||
});
|
||||
|
||||
const callbackUrl = buildCallbackUrl({
|
||||
providerId: provider.id,
|
||||
type: provider.type,
|
||||
});
|
||||
|
||||
const samlEntityId = buildSamlEntityId(provider.id);
|
||||
|
||||
const handleSubmit = async (values: SSOFormValues) => {
|
||||
const ssoData: Partial<IAuthProvider> = {
|
||||
providerId: provider.id,
|
||||
};
|
||||
if (form.isDirty("name")) {
|
||||
ssoData.name = values.name;
|
||||
}
|
||||
if (form.isDirty("samlUrl")) {
|
||||
ssoData.samlUrl = values.samlUrl;
|
||||
}
|
||||
if (form.isDirty("samlCertificate")) {
|
||||
ssoData.samlCertificate = values.samlCertificate;
|
||||
}
|
||||
if (form.isDirty("isEnabled")) {
|
||||
ssoData.isEnabled = values.isEnabled;
|
||||
}
|
||||
if (form.isDirty("allowSignup")) {
|
||||
ssoData.allowSignup = values.allowSignup;
|
||||
}
|
||||
|
||||
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 Azure Entra"
|
||||
data-autofocus
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Entity ID"
|
||||
variant="filled"
|
||||
value={buildSamlEntityId(provider.id)}
|
||||
rightSection={<CopyTextButton text={samlEntityId} />}
|
||||
pointer
|
||||
readOnly
|
||||
/>
|
||||
<TextInput
|
||||
label="Callback URL (ACS)"
|
||||
variant="filled"
|
||||
value={callbackUrl}
|
||||
pointer
|
||||
readOnly
|
||||
rightSection={<CopyTextButton text={callbackUrl} />}
|
||||
/>
|
||||
<TextInput
|
||||
label="IDP Login URL"
|
||||
description="Enter your IDP login URL"
|
||||
placeholder="e.g https://login.microsoftonline.com/7d6246d1-273b-4981-ad1e-e7bb27b86569/saml2"
|
||||
{...form.getInputProps("samlUrl")}
|
||||
/>
|
||||
<Textarea
|
||||
label="IDP Certificate"
|
||||
description="Enter your IDP certificate"
|
||||
placeholder="-----BEGIN CERTIFICATE-----"
|
||||
autosize
|
||||
minRows={3}
|
||||
maxRows={5}
|
||||
{...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("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>
|
||||
);
|
||||
}
|
||||
14
apps/client/src/ee/security/components/sso.module.css
Normal file
14
apps/client/src/ee/security/components/sso.module.css
Normal file
@ -0,0 +1,14 @@
|
||||
.item {
|
||||
& + & {
|
||||
padding-top: var(--mantine-spacing-sm);
|
||||
margin-top: var(--mantine-spacing-sm);
|
||||
border-top: 1px solid
|
||||
light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
}
|
||||
}
|
||||
|
||||
.switch {
|
||||
& * {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user