Merge branch 'main' into confluence

This commit is contained in:
Philipinho
2026-05-15 13:26:33 +01:00
372 changed files with 21229 additions and 5574 deletions
@@ -132,6 +132,7 @@ export default function AiChatSidebarItem({
size="xs"
color="gray"
onClick={(e) => e.preventDefault()}
aria-label={t("Chat menu")}
>
<IconDots size={14} />
</ActionIcon>
@@ -137,7 +137,8 @@ export default function AiChatSidebar() {
<TextInput
className={classes.searchInput}
placeholder="Search chats..."
placeholder={t("Search chats...")}
aria-label={t("Search chats")}
leftSection={<IconSearch size={14} />}
size="xs"
value={search}
@@ -178,6 +178,7 @@ export default function AsideChatPanel() {
href="/ai"
variant="subtle"
color="dark"
aria-label={t("New chat")}
onClick={handleNewChat}
>
<IconPlus size={20} stroke={1.75} />
@@ -185,13 +186,23 @@ export default function AsideChatPanel() {
</Tooltip>
<Tooltip label={t("Open full page")} openDelay={250}>
<ActionIcon variant="subtle" color="dark" onClick={handleExpand}>
<ActionIcon
variant="subtle"
color="dark"
aria-label={t("Open full page")}
onClick={handleExpand}
>
<IconArrowsDiagonal size={18} stroke={1.5} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Close")} openDelay={250}>
<ActionIcon variant="subtle" color="dark" onClick={handleClose}>
<ActionIcon
variant="subtle"
color="dark"
aria-label={t("Close")}
onClick={handleClose}
>
<IconX size={20} stroke={1.75} />
</ActionIcon>
</Tooltip>
@@ -65,7 +65,7 @@ export default function ChatEmptyState({ isStreaming, onSend, onStop }: Props) {
isStreaming={isStreaming}
onSend={onSend}
onStop={onStop}
placeholder="Ask anything... Use @ to mention pages"
placeholder={t("Ask anything... Use @ to mention pages")}
autofocus
/>
</div>
@@ -200,7 +200,7 @@ export default function ChatInput({
link: false,
}),
Placeholder.configure({
placeholder: placeholder || "Ask anything... Use @ to mention pages",
placeholder: placeholder || t("Ask anything... Use @ to mention pages"),
}),
CharacterCount.configure({
limit: 50000,
@@ -225,6 +225,10 @@ export default function ChatInput({
}),
],
editorProps: {
attributes: {
"aria-label": placeholder || t("Ask anything... Use @ to mention pages"),
"aria-multiline": "true",
},
handleDOMEvents: {
keydown: (_view, event) => {
if (
@@ -275,6 +279,8 @@ export default function ChatInput({
type="file"
accept={ACCEPTED_FILE_TYPES}
multiple
aria-label={t("Add files")}
tabIndex={-1}
style={{ display: "none" }}
onChange={(e) => handleFileSelect(e.target.files)}
/>
@@ -31,7 +31,16 @@ export default function ChatToolGroup({ toolCalls, isStreaming }: Props) {
<div className={classes.toolGroup}>
<div
className={classes.toolGroupHeader}
role="button"
tabIndex={0}
aria-expanded={expanded}
onClick={() => setExpanded((prev) => !prev)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setExpanded((prev) => !prev);
}
}}
>
{activeLabel ? (
<IconLoader2 size={12} className={classes.processingSpinner} />
@@ -98,7 +98,7 @@
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
color: var(--mantine-color-dimmed);
margin-bottom: var(--mantine-spacing-xs);
}
@@ -125,7 +125,7 @@
.suggestionsLabel {
font-size: var(--mantine-font-size-xs);
font-weight: 500;
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
color: var(--mantine-color-dimmed);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: var(--mantine-spacing-sm);
@@ -43,7 +43,7 @@
margin-top: 6px;
text-align: center;
font-size: var(--mantine-font-size-xs);
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
color: var(--mantine-color-dimmed);
}
.attachmentChips {
@@ -36,7 +36,7 @@
padding: 4px var(--mantine-spacing-xs);
font-size: var(--mantine-font-size-xs);
font-weight: 600;
color: light-dark(var(--mantine-color-gray-6), var(--mantine-color-dark-2));
color: var(--mantine-color-dimmed);
user-select: none;
}
@@ -104,7 +104,7 @@
.chatItemDate {
font-size: var(--mantine-font-size-xs);
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
color: var(--mantine-color-dimmed);
white-space: nowrap;
transition: opacity 150ms;
}
@@ -31,7 +31,7 @@ export function ApiKeyCreatedModal({
<Modal
opened={opened}
onClose={onClose}
title={t("API key created")}
title={t("{{credential}} created", { credential: t("API key") })}
size="lg"
>
<Stack gap="md">
@@ -41,7 +41,8 @@ export function ApiKeyCreatedModal({
color="red"
>
{t(
"Make sure to copy your API key now. You won't be able to see it again!",
"Make sure to copy your {{credential}} now. You won't be able to see it again!",
{ credential: t("API key") },
)}
</Alert>
@@ -64,7 +65,7 @@ export function ApiKeyCreatedModal({
</div>
<Button fullWidth onClick={onClose} mt="md">
{t("I've saved my API key")}
{t("I've saved my {{credential}}", { credential: t("API key") })}
</Button>
</Stack>
</Modal>
@@ -44,7 +44,7 @@ export function ApiKeyTable({
<Table.Th>{t("Last used")}</Table.Th>
<Table.Th>{t("Expires")}</Table.Th>
<Table.Th>{t("Created")}</Table.Th>
<Table.Th></Table.Th>
<Table.Th aria-label={t("Action")} />
</Table.Tr>
</Table.Thead>
@@ -106,7 +106,11 @@ export function ApiKeyTable({
<Table.Td>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("API key menu")}
>
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
@@ -105,7 +105,7 @@ export function CreateApiKeyModal({
<Modal
opened={opened}
onClose={handleClose}
title={t("Create API Key")}
title={t("Create {{credential}}", { credential: t("API key") })}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
@@ -30,12 +30,14 @@ export function RevokeApiKeyModal({
<Modal
opened={opened}
onClose={onClose}
title={t("Revoke API key")}
title={t("Revoke {{credential}}", { credential: t("API key") })}
size="md"
>
<Stack gap="md">
<Text>
{t("Are you sure you want to revoke this API key")}{" "}
{t("Are you sure you want to revoke this {{credential}}", {
credential: t("API key"),
})}{" "}
<strong>{apiKey?.name}</strong>?
</Text>
<Text size="sm" c="dimmed">
@@ -53,7 +53,7 @@ export function UpdateApiKeyModal({
<Modal
opened={opened}
onClose={onClose}
title={t("Update API key")}
title={t("Update {{credential}}", { credential: t("API key") })}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
@@ -63,7 +63,11 @@ export function useCreateApiKeyMutation() {
return useMutation<IApiKey, Error, ICreateApiKeyRequest>({
mutationFn: (data) => createApiKey(data),
onSuccess: () => {
notifications.show({ message: t("API key created successfully") });
notifications.show({
message: t("{{credential}} created successfully", {
credential: t("API key"),
}),
});
queryClient.invalidateQueries({
predicate: (item) =>
["api-key-list"].includes(item.queryKey[0] as string),
@@ -33,6 +33,10 @@ export const auditEventLabels: Record<string, string> = {
"api_key.updated": "Updated API key",
"api_key.deleted": "Deleted API key",
"scim_token.created": "Created SCIM token",
"scim_token.updated": "Updated SCIM token",
"scim_token.deleted": "Deleted SCIM token",
"space.created": "Created space",
"space.updated": "Updated space",
"space.deleted": "Deleted space",
@@ -174,6 +178,14 @@ export const eventFilterOptions: EventGroup[] = [
{ value: "api_key.deleted", label: "Deleted API key" },
],
},
{
group: "SCIM token",
items: [
{ value: "scim_token.created", label: "Created SCIM token" },
{ value: "scim_token.updated", label: "Updated SCIM token" },
{ value: "scim_token.deleted", label: "Deleted SCIM token" },
],
},
{
group: "License",
items: [
+64 -5
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useWorkspacePublicDataQuery } from "@/features/workspace/queries/workspace-query.ts";
import { Button, Divider, Stack } from "@mantine/core";
import { IconLock, IconServer } from "@tabler/icons-react";
@@ -7,15 +7,37 @@ 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 { LdapLoginModal } from "@/ee/components/ldap-login-modal.tsx";
import { getRedirectParam } from "@/lib/app-route.ts";
import useCurrentUser from "@/features/user/hooks/use-current-user.ts";
const SSO_AUTO_ATTEMPT_KEY = "docmost:ssoAutoAttempt";
const SSO_AUTO_ATTEMPT_TTL_MS = 5 * 60_000;
function recentAutoAttempt(): boolean {
try {
const raw = window.sessionStorage.getItem(SSO_AUTO_ATTEMPT_KEY);
if (!raw) return false;
const ts = Number(raw);
return Number.isFinite(ts) && Date.now() - ts < SSO_AUTO_ATTEMPT_TTL_MS;
} catch {
return false;
}
}
function markAutoAttempt(): void {
try {
window.sessionStorage.setItem(SSO_AUTO_ATTEMPT_KEY, String(Date.now()));
} catch {
/* sessionStorage unavailable (private mode, etc.) — best effort */
}
}
export default function SsoLogin() {
const { data, isLoading } = useWorkspacePublicDataQuery();
const { data: currentUser } = useCurrentUser();
const [ldapModalOpened, setLdapModalOpened] = useState(false);
const [selectedLdapProvider, setSelectedLdapProvider] = useState<IAuthProvider | null>(null);
if (!data?.authProviders || data?.authProviders?.length === 0) {
return null;
}
const autoRedirectedRef = useRef(false);
const handleSsoLogin = (provider: IAuthProvider) => {
if (provider.type === SSO_PROVIDER.LDAP) {
@@ -28,10 +50,47 @@ export default function SsoLogin() {
providerId: provider.id,
type: provider.type,
workspaceId: data.id,
redirect: getRedirectParam() ?? undefined,
});
}
};
// Auto-redirect when SSO is enforced and there is exactly one non-LDAP
// provider. The user has no other option, so skip the extra click.
useEffect(() => {
if (autoRedirectedRef.current) return;
if (!data?.enforceSso) return;
if (!data.authProviders || data.authProviders.length !== 1) return;
const onlyProvider = data.authProviders[0];
if (onlyProvider.type === SSO_PROVIDER.LDAP) return;
// Already signed in: let useRedirectIfAuthenticated handle navigation
// instead of racing it through the IdP.
if (currentUser?.user) return;
// Explicit logout: don't immediately bounce them back to the IdP.
const params = new URLSearchParams(window.location.search);
if (params.has("logout")) return;
// Circuit-breaker: if we already auto-redirected within the TTL, the
// user came back (likely from an IdP failure). Show the page so they
// can read errors or pick a different account.
if (recentAutoAttempt()) return;
autoRedirectedRef.current = true;
markAutoAttempt();
window.location.href = buildSsoLoginUrl({
providerId: onlyProvider.id,
type: onlyProvider.type,
workspaceId: data.id,
redirect: getRedirectParam() ?? undefined,
});
}, [data, currentUser]);
if (!data?.authProviders || data?.authProviders?.length === 0) {
return null;
}
const getProviderIcon = (provider: IAuthProvider) => {
if (provider.type === SSO_PROVIDER.GOOGLE) {
return <GoogleIcon size={16} />;
+1
View File
@@ -8,6 +8,7 @@ export const Feature = {
AI: 'ai',
CONFLUENCE_IMPORT: 'import:confluence',
DOCX_IMPORT: 'import:docx',
PDF_IMPORT: 'import:pdf',
ATTACHMENT_INDEXING: 'attachment:indexing',
SECURITY_SETTINGS: 'security:settings',
MCP: 'mcp',
@@ -140,7 +140,7 @@ export function PagePermissionList({
)}
</Group>
<ScrollArea mah={250} viewportRef={viewportRef}>
<ScrollArea.Autosize mah={400} viewportRef={viewportRef}>
{sortedMembers.map((member) => (
<PagePermissionItem
key={`${member.type}-${member.id}`}
@@ -158,7 +158,7 @@ export function PagePermissionList({
<Loader size="xs" />
</Center>
)}
</ScrollArea>
</ScrollArea.Autosize>
</>
);
}
@@ -1,4 +1,12 @@
import { ActionIcon, Group, Menu, Modal, Text, Tooltip } from "@mantine/core";
import {
ActionIcon,
Group,
Menu,
Modal,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
IconRosetteDiscountCheckFilled,
@@ -38,6 +46,7 @@ export function PageVerificationModal({
<Modal
opened={opened}
onClose={onClose}
aria-label={status === "none" ? t("Set up verification") : t("Verify page")}
title={
<Group gap="xs">
<IconShieldCheck
@@ -97,9 +106,9 @@ export function PageVerificationBadge({
withArrow
openDelay={250}
>
<ActionIcon variant="subtle" color="gray">
<ThemeIcon variant="subtle" color="gray">
<IconShieldCheck size={20} stroke={1.5} />
</ActionIcon>
</ThemeIcon>
</Tooltip>
);
}
@@ -109,10 +118,20 @@ export function PageVerificationBadge({
if (status === "none" && readOnly) return null;
const tooltipLabel =
status === "verified" && verificationInfo?.expiresAt
? t("Verified until {{date}}", {
date: new Date(verificationInfo.expiresAt).toLocaleDateString(
undefined,
{ month: "long", day: "numeric", year: "numeric" },
),
})
: getStatusLabel(status, t);
return (
<>
{status !== "none" ? (
<Tooltip label={getStatusLabel(status, t)} withArrow openDelay={250}>
<Tooltip label={tooltipLabel} withArrow openDelay={250}>
<Group
gap={4}
onClick={open}
@@ -130,7 +149,12 @@ export function PageVerificationBadge({
</Tooltip>
) : !readOnly ? (
<Tooltip label={t("Set up verification")} withArrow openDelay={250}>
<ActionIcon variant="subtle" color="gray" onClick={open}>
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("Set up verification")}
onClick={open}
>
<IconShieldCheck size={20} stroke={1.5} />
</ActionIcon>
</Tooltip>
@@ -0,0 +1,78 @@
import { Modal, TextInput, Button, Group, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
import { useTranslation } from "react-i18next";
import { useCreateScimTokenMutation } from "@/ee/scim/queries/scim-token-query";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
interface CreateScimTokenModalProps {
opened: boolean;
onClose: () => void;
onSuccess: (response: IScimToken) => void;
}
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
});
type FormValues = z.infer<typeof formSchema>;
export function CreateScimTokenModal({
opened,
onClose,
onSuccess,
}: CreateScimTokenModalProps) {
const { t } = useTranslation();
const createMutation = useCreateScimTokenMutation();
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: { name: "" },
});
const handleSubmit = async (data: FormValues) => {
try {
const created = await createMutation.mutateAsync({ name: data.name });
onSuccess(created);
form.reset();
onClose();
} catch (err) {
//
}
};
const handleClose = () => {
form.reset();
onClose();
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={t("Create {{credential}}", { credential: t("SCIM token") })}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
<TextInput
label={t("Name")}
placeholder={t("Enter a descriptive name")}
data-autofocus
required
{...form.getInputProps("name")}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={handleClose}>
{t("Cancel")}
</Button>
<Button type="submit" loading={createMutation.isPending}>
{t("Create")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}
@@ -0,0 +1,55 @@
import { Group, Text, Switch, Tooltip } 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";
import { useHasFeature } from "@/ee/hooks/use-feature.ts";
import { Feature } from "@/ee/features.ts";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
export default function EnableScim() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.isScimEnabled ?? false);
const hasAccess = useHasFeature(Feature.SCIM);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({ isScimEnabled: value });
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Enable SCIM")}</Text>
<Text size="sm" c="dimmed">
{t(
"Automatically provision users and groups from your identity provider via SCIM.",
)}
</Text>
</div>
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
labelPosition="left"
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle SCIM provisioning")}
/>
</Tooltip>
</Group>
);
}
@@ -0,0 +1,61 @@
import { Modal, Text, Button, Group, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useRevokeScimTokenMutation } from "@/ee/scim/queries/scim-token-query";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
interface RevokeScimTokenModalProps {
opened: boolean;
onClose: () => void;
scimToken: IScimToken | null;
}
export function RevokeScimTokenModal({
opened,
onClose,
scimToken,
}: RevokeScimTokenModalProps) {
const { t } = useTranslation();
const revokeMutation = useRevokeScimTokenMutation();
const handleRevoke = async () => {
if (!scimToken) return;
await revokeMutation.mutateAsync({ tokenId: scimToken.id });
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Revoke {{credential}}", { credential: t("SCIM token") })}
size="md"
>
<Stack gap="md">
<Text>
{t("Are you sure you want to revoke this {{credential}}", {
credential: t("SCIM token"),
})}{" "}
<strong>{scimToken?.name}</strong>?
</Text>
<Text size="sm" c="dimmed">
{t(
"This action cannot be undone. Your identity provider will stop syncing immediately.",
)}
</Text>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button
color="red"
onClick={handleRevoke}
loading={revokeMutation.isPending}
>
{t("Revoke")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -0,0 +1,69 @@
import {
Modal,
Text,
Stack,
Alert,
Group,
Button,
TextInput,
} from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import CopyTextButton from "@/components/common/copy.tsx";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
interface ScimTokenCreatedModalProps {
opened: boolean;
onClose: () => void;
scimToken: IScimToken | null;
}
export function ScimTokenCreatedModal({
opened,
onClose,
scimToken,
}: ScimTokenCreatedModalProps) {
const { t } = useTranslation();
if (!scimToken) return null;
return (
<Modal
opened={opened}
onClose={onClose}
title={t("{{credential}} created", { credential: t("SCIM token") })}
size="lg"
>
<Stack gap="md">
<Alert
icon={<IconAlertTriangle size={16} />}
title={t("Important")}
color="red"
>
{t(
"Make sure to copy your {{credential}} now. You won't be able to see it again!",
{ credential: t("SCIM token") },
)}
</Alert>
<div>
<Text size="sm" fw={500} mb="xs">
{t("SCIM token")}
</Text>
<Group gap="xs" wrap="nowrap">
<TextInput
variant="filled"
style={{ flex: 1 }}
value={scimToken.token}
readOnly
/>
<CopyTextButton text={scimToken.token} />
</Group>
</div>
<Button fullWidth onClick={onClose} mt="md">
{t("I've saved my {{credential}}", { credential: t("SCIM token") })}
</Button>
</Stack>
</Modal>
);
}
@@ -0,0 +1,130 @@
import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core";
import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
import { format } from "date-fns";
import { useTranslation } from "react-i18next";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import React from "react";
import NoTableResults from "@/components/common/no-table-results";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
interface ScimTokenTableProps {
tokens: IScimToken[];
isLoading?: boolean;
onUpdate?: (token: IScimToken) => void;
onRevoke?: (token: IScimToken) => void;
}
export function ScimTokenTable({
tokens,
isLoading,
onUpdate,
onRevoke,
}: ScimTokenTableProps) {
const { t } = useTranslation();
const formatDate = (date: Date | string | null) => {
if (!date) return t("Never");
return format(new Date(date), "MMM dd, yyyy");
};
return (
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Name")}</Table.Th>
<Table.Th>{t("Token")}</Table.Th>
<Table.Th>{t("Created by")}</Table.Th>
<Table.Th>{t("Last used")}</Table.Th>
<Table.Th>{t("Created")}</Table.Th>
<Table.Th aria-label={t("Action")} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{tokens && tokens.length > 0 ? (
tokens.map((token) => (
<Table.Tr key={token.id}>
<Table.Td>
<Text fz="sm" fw={500}>
{token.name}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" ff="monospace" c="dimmed">
{token.tokenLastFour}
</Text>
</Table.Td>
{token.creator ? (
<Table.Td>
<Group gap="4" wrap="nowrap">
<CustomAvatar
avatarUrl={token.creator?.avatarUrl}
name={token.creator.name}
size="sm"
/>
<Text fz="sm" lineClamp={1}>
{token.creator.name}
</Text>
</Group>
</Table.Td>
) : (
<Table.Td>
<Text fz="sm" c="dimmed">
</Text>
</Table.Td>
)}
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(token.lastUsedAt)}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(token.createdAt)}
</Text>
</Table.Td>
<Table.Td>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{onUpdate && (
<Menu.Item
leftSection={<IconEdit size={16} />}
onClick={() => onUpdate(token)}
>
{t("Rename")}
</Menu.Item>
)}
{onRevoke && (
<Menu.Item
leftSection={<IconTrash size={16} />}
color="red"
onClick={() => onRevoke(token)}
>
{t("Revoke")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</Table.Td>
</Table.Tr>
))
) : (
<NoTableResults colSpan={6} />
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
@@ -0,0 +1,30 @@
import { Group, Stack, Text, TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import CopyTextButton from "@/components/common/copy.tsx";
export function ScimUrlPanel() {
const { t } = useTranslation();
const scimUrl = `${window.location.origin}/api/scim/v2`;
return (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("SCIM endpoint URL")}
</Text>
<Text size="xs" c="dimmed">
{t(
"Configure your identity provider with this URL to provision users and groups.",
)}
</Text>
<Group gap="xs" wrap="nowrap">
<TextInput
variant="filled"
style={{ flex: 1 }}
value={scimUrl}
readOnly
/>
<CopyTextButton text={scimUrl} />
</Group>
</Stack>
);
}
@@ -0,0 +1,77 @@
import { Modal, TextInput, Button, Group, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
import { z } from "zod/v4";
import { useTranslation } from "react-i18next";
import { useEffect } from "react";
import { useUpdateScimTokenMutation } from "@/ee/scim/queries/scim-token-query";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
});
type FormValues = z.infer<typeof formSchema>;
interface UpdateScimTokenModalProps {
opened: boolean;
onClose: () => void;
scimToken: IScimToken | null;
}
export function UpdateScimTokenModal({
opened,
onClose,
scimToken,
}: UpdateScimTokenModalProps) {
const { t } = useTranslation();
const updateMutation = useUpdateScimTokenMutation();
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: { name: "" },
});
useEffect(() => {
if (opened && scimToken) {
form.setValues({ name: scimToken.name });
}
}, [opened, scimToken]);
const handleSubmit = async (data: FormValues) => {
if (!scimToken) return;
await updateMutation.mutateAsync({
tokenId: scimToken.id,
name: data.name,
});
onClose();
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("Update {{credential}}", { credential: t("SCIM token") })}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack gap="md">
<TextInput
label={t("Name")}
placeholder={t("Enter a descriptive name")}
required
{...form.getInputProps("name")}
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
{t("Cancel")}
</Button>
<Button type="submit" loading={updateMutation.isPending}>
{t("Update")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./types/scim-token.types";
export * from "./services/scim-token-service";
@@ -0,0 +1,96 @@
import { IPagination, QueryParams } from "@/lib/types.ts";
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
createScimToken,
getScimTokens,
revokeScimToken,
updateScimToken,
} from "@/ee/scim/services/scim-token-service";
import {
IScimToken,
ICreateScimTokenRequest,
IRevokeScimTokenRequest,
IUpdateScimTokenRequest,
} from "@/ee/scim/types/scim-token.types";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function useGetScimTokensQuery(
params?: QueryParams,
): UseQueryResult<IPagination<IScimToken>, Error> {
return useQuery({
queryKey: ["scim-token-list", params],
queryFn: () => getScimTokens(params),
placeholderData: keepPreviousData,
});
}
export function useCreateScimTokenMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IScimToken, Error, ICreateScimTokenRequest>({
mutationFn: (data) => createScimToken(data),
onSuccess: () => {
notifications.show({
message: t("{{credential}} created successfully", {
credential: t("SCIM token"),
}),
});
queryClient.invalidateQueries({
predicate: (item) =>
["scim-token-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useUpdateScimTokenMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, IUpdateScimTokenRequest>({
mutationFn: (data) => updateScimToken(data),
onSuccess: () => {
notifications.show({ message: t("Updated successfully") });
queryClient.invalidateQueries({
predicate: (item) =>
["scim-token-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useRevokeScimTokenMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, IRevokeScimTokenRequest>({
mutationFn: (data) => revokeScimToken(data),
onSuccess: () => {
notifications.show({ message: t("Revoked successfully") });
queryClient.invalidateQueries({
predicate: (item) =>
["scim-token-list"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
@@ -0,0 +1,34 @@
import api from "@/lib/api-client";
import {
IScimToken,
ICreateScimTokenRequest,
IRevokeScimTokenRequest,
IUpdateScimTokenRequest,
} from "@/ee/scim/types/scim-token.types";
import { IPagination, QueryParams } from "@/lib/types.ts";
export async function getScimTokens(
params?: QueryParams,
): Promise<IPagination<IScimToken>> {
const req = await api.post("/scim-tokens", { ...params });
return req.data;
}
export async function createScimToken(
data: ICreateScimTokenRequest,
): Promise<IScimToken> {
const req = await api.post<IScimToken>("/scim-tokens/create", data);
return req.data;
}
export async function updateScimToken(
data: IUpdateScimTokenRequest,
): Promise<void> {
await api.post("/scim-tokens/update", data);
}
export async function revokeScimToken(
data: IRevokeScimTokenRequest,
): Promise<void> {
await api.post("/scim-tokens/revoke", data);
}
@@ -0,0 +1,27 @@
import { IUser } from "@/features/user/types/user.types.ts";
export interface IScimToken {
id: string;
name: string;
token?: string;
tokenLastFour: string;
isEnabled: boolean;
creatorId: string;
workspaceId: string;
lastUsedAt: string | null;
createdAt: string;
creator?: Partial<IUser>;
}
export interface ICreateScimTokenRequest {
name: string;
}
export interface IUpdateScimTokenRequest {
tokenId: string;
name: string;
}
export interface IRevokeScimTokenRequest {
tokenId: string;
}
@@ -69,8 +69,8 @@ export default function SsoProviderList() {
return (
<>
<Card shadow="sm" radius="sm">
<Table.ScrollContainer minWidth={600}>
<Table verticalSpacing="sm">
<Table.ScrollContainer minWidth={600} maxHeight={400}>
<Table verticalSpacing="sm" stickyHeader>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Name")}</Table.Th>
@@ -141,6 +141,7 @@ export default function SsoProviderList() {
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("Edit {{name}}", { name: provider.name })}
onClick={() => handleEdit(provider)}
>
<IconPencil size={16} />
@@ -152,7 +153,13 @@ export default function SsoProviderList() {
withinPortal
>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<ActionIcon
variant="subtle"
color="gray"
aria-label={t("More actions for {{name}}", {
name: provider.name,
})}
>
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
+137 -6
View File
@@ -1,8 +1,18 @@
import { Helmet } from "react-helmet-async";
import { getAppName, isCloud } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx";
import { Divider, Title } from "@mantine/core";
import React from "react";
import {
Alert,
Button,
Card,
Divider,
Group,
Space,
Title,
Tooltip,
} from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import React, { useState } from "react";
import useUserRole from "@/hooks/use-user-role.tsx";
import SsoProviderList from "@/ee/security/components/sso-provider-list.tsx";
import CreateSsoProvider from "@/ee/security/components/create-sso-provider.tsx";
@@ -12,16 +22,41 @@ import { useTranslation } from "react-i18next";
import EnforceMfa from "@/ee/security/components/enforce-mfa.tsx";
import DisablePublicSharing from "@/ee/security/components/disable-public-sharing.tsx";
import TrashRetention from "@/ee/security/components/trash-retention.tsx";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useGetScimTokensQuery } from "@/ee/scim/queries/scim-token-query";
import { ScimUrlPanel } from "@/ee/scim/components/scim-url-panel";
import { ScimTokenTable } from "@/ee/scim/components/scim-token-table";
import { CreateScimTokenModal } from "@/ee/scim/components/create-scim-token-modal";
import { ScimTokenCreatedModal } from "@/ee/scim/components/scim-token-created-modal";
import { RevokeScimTokenModal } from "@/ee/scim/components/revoke-scim-token-modal";
import { UpdateScimTokenModal } from "@/ee/scim/components/update-scim-token-modal";
import EnableScim from "@/ee/scim/components/enable-scim";
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
import Paginate from "@/components/common/paginate";
import { IScimToken } from "@/ee/scim/types/scim-token.types";
const SCIM_TOKEN_LIMIT = 5;
export default function Security() {
const { t } = useTranslation();
const { isAdmin } = useUserRole();
const hasCustomSso = useHasFeature(Feature.SSO_CUSTOM);
const hasRetention = useHasFeature(Feature.RETENTION);
const hasSharingControls = useHasFeature(Feature.SHARING_CONTROLS);
const hasScim = useHasFeature(Feature.SCIM);
const [workspace] = useAtom(workspaceAtom);
const isScimEnabled = workspace?.isScimEnabled ?? false;
const { cursor, goNext, goPrev } = useCursorPaginate();
const { data: scimData, isLoading: scimLoading } = useGetScimTokensQuery(
hasScim && isScimEnabled ? { cursor } : undefined,
);
const [createOpen, setCreateOpen] = useState(false);
const [createdToken, setCreatedToken] = useState<IScimToken | null>(null);
const [updateTarget, setUpdateTarget] = useState<IScimToken | null>(null);
const [revokeTarget, setRevokeTarget] = useState<IScimToken | null>(null);
if (!isAdmin) {
return null;
@@ -45,7 +80,7 @@ export default function Security() {
<Divider my="lg" />
<Title order={4} my="lg">
Single sign-on (SSO)
{t("Single sign-on (SSO)")}
</Title>
<EnforceSso />
@@ -66,6 +101,102 @@ export default function Security() {
)}
<SsoProviderList />
{hasScim && (
<>
<Divider my="xl" />
<Title order={4} my="lg">
{t("SCIM provisioning")}
</Title>
<Alert
icon={<IconInfoCircle size={16} />}
color="blue"
variant="light"
mb="md"
>
{t("SCIM takes precedence over SSO group sync while enabled.")}
</Alert>
<EnableScim />
<Divider my="lg" />
<ScimUrlPanel />
{isScimEnabled && (
<>
<Divider my="lg" />
<Group justify="space-between" mb="md">
<Title order={5}>{t("SCIM tokens")}</Title>
<Tooltip
label={t(
"You have reached the maximum of {{max}} SCIM tokens. Delete an existing token to create a new one.",
{ max: SCIM_TOKEN_LIMIT },
)}
disabled={(scimData?.items.length ?? 0) < SCIM_TOKEN_LIMIT}
refProp="rootRef"
>
<Button
onClick={() => setCreateOpen(true)}
disabled={(scimData?.items.length ?? 0) >= SCIM_TOKEN_LIMIT}
>
{t("Create {{credential}}", {
credential: t("SCIM token"),
})}
</Button>
</Tooltip>
</Group>
<Card shadow="sm" radius="sm">
<ScimTokenTable
tokens={scimData?.items}
isLoading={scimLoading}
onUpdate={setUpdateTarget}
onRevoke={setRevokeTarget}
/>
</Card>
<Space h="md" />
{scimData?.items.length > 0 && (
<Paginate
hasPrevPage={scimData?.meta?.hasPrevPage}
hasNextPage={scimData?.meta?.hasNextPage}
onNext={() => goNext(scimData?.meta?.nextCursor)}
onPrev={goPrev}
/>
)}
<CreateScimTokenModal
opened={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={setCreatedToken}
/>
<ScimTokenCreatedModal
opened={!!createdToken}
onClose={() => setCreatedToken(null)}
scimToken={createdToken}
/>
<UpdateScimTokenModal
opened={!!updateTarget}
onClose={() => setUpdateTarget(null)}
scimToken={updateTarget}
/>
<RevokeScimTokenModal
opened={!!revokeTarget}
onClose={() => setRevokeTarget(null)}
scimToken={revokeTarget}
/>
</>
)}
</>
)}
</>
);
}
+10 -3
View File
@@ -18,14 +18,21 @@ export function buildSsoLoginUrl(opts: {
providerId: string;
type: SSO_PROVIDER;
workspaceId?: string;
redirect?: string;
}): string {
const { providerId, type, workspaceId } = opts;
const { providerId, type, workspaceId, redirect } = opts;
const domain = getAppUrl();
const params = new URLSearchParams();
if (redirect) params.set("redirect", redirect);
if (type === SSO_PROVIDER.GOOGLE) {
return `${getServerAppUrl()}/api/sso/${type}/login?workspaceId=${workspaceId}`;
if (workspaceId) params.set("workspaceId", workspaceId);
return `${getServerAppUrl()}/api/sso/${type}/login?${params.toString()}`;
}
return `${domain}/api/sso/${type}/${providerId}/login`;
const query = params.toString();
const base = `${domain}/api/sso/${type}/${providerId}/login`;
return query ? `${base}?${query}` : base;
}
export function getGoogleSignupUrl(): string {
@@ -56,6 +56,7 @@ export default function TemplateCard({
color="gray"
className={classes.menuTarget}
onClick={(e) => e.stopPropagation()}
aria-label={t("Template menu")}
>
<IconDots size={16} />
</ActionIcon>
@@ -24,7 +24,7 @@ export default function TemplatePreviewModal({
const title = template?.title || t("Untitled");
return (
<Modal.Root size={1200} opened={opened} onClose={onClose}>
<Modal.Root size={1200} opened={opened} onClose={onClose} aria-label={title}>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Header>