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:
Philip Okugbe
2025-03-06 13:38:37 +00:00
committed by GitHub
parent 91596be70e
commit b81c9ee10c
148 changed files with 8947 additions and 3458 deletions

View 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>
</>
);
}

View File

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

View 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")}
/>
);
}

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

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

View 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}
/>
</>
);
}

View File

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

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

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