diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 4275b5af..f0410738 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -420,6 +420,8 @@ "{{count}} command available_other": "{{count}} commands available", "{{count}} result available_one": "1 result available", "{{count}} result available_other": "{{count}} results available", + "{{count}} result found_one": "{{count}} result found", + "{{count}} result found_other": "{{count}} results found", "Equal columns": "Equal columns", "Left sidebar": "Left sidebar", "Right sidebar": "Right sidebar", @@ -1127,6 +1129,18 @@ "Removed from favorites": "Removed from favorites", "Added {{name}} to favorites": "Added {{name}} to favorites", "Removed {{name}} from favorites": "Removed {{name}} from favorites", + "Label added": "Label added", + "Label removed": "Label removed", + "Image updated": "Image updated", + "Unsupported image type": "Unsupported image type", + "Member deactivated": "Member deactivated", + "Member activated": "Member activated", + "Name is required": "Name is required", + "Name must be 40 characters or fewer": "Name must be 40 characters or fewer", + "Group name must be at least 2 characters": "Group name must be at least 2 characters", + "Group name must be 100 characters or fewer": "Group name must be 100 characters or fewer", + "Description must be 500 characters or fewer": "Description must be 500 characters or fewer", + "Invalid invitation link": "Invalid invitation link", "Page menu for {{name}}": "Page menu for {{name}}", "Create subpage of {{name}}": "Create subpage of {{name}}", "AI chat": "AI chat", @@ -1135,7 +1149,6 @@ "Thinking…": "Thinking…", "The assistant is unavailable right now. Please try again.": "The assistant is unavailable right now. Please try again.", "Public share assistant": "Public share assistant", - "Enabled": "Enabled", "Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.": "Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.", "Public assistant model": "Public assistant model", "Defaults to the chat model": "Defaults to the chat model", @@ -1264,5 +1277,7 @@ "Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.": "Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.", "Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.": "Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.", "Analytics / tracker": "Analytics / tracker", - "Injected verbatim into the of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Injected verbatim into the of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only." + "Injected verbatim into the of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Injected verbatim into the of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.", + "Go to login page": "Go to login page", + "Move to space": "Move to space" } diff --git a/apps/client/src/components/common/avatar-uploader.tsx b/apps/client/src/components/common/avatar-uploader.tsx index d7ac5f40..ec98aa02 100644 --- a/apps/client/src/components/common/avatar-uploader.tsx +++ b/apps/client/src/components/common/avatar-uploader.tsx @@ -42,6 +42,23 @@ export default function AvatarUploader({ return; } + // Validate file type. The `accept` attribute only filters the dialog; + // a user can still select a non-image file, which previously failed + // silently. Surface a visible error instead (issue #133). Accept any + // image/* MIME (png, jpeg, webp, gif, svg, ...) so we don't narrow below + // what the server accepts; only genuinely non-image files are rejected. + if (!file.type.startsWith("image/")) { + notifications.show({ + message: t("Unsupported image type"), + color: "red", + }); + // Reset the input + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + return; + } + // Validate file size (max 10MB) const maxSizeInBytes = 10 * 1024 * 1024; if (file.size > maxSizeInBytes) { @@ -58,6 +75,8 @@ export default function AvatarUploader({ try { await onUpload(file); + // Notify on success so the upload gives visible feedback (issue #128) + notifications.show({ message: t("Image updated") }); } catch (error) { console.error(error); notifications.show({ @@ -117,7 +136,7 @@ export default function AvatarUploader({ type="file" ref={fileInputRef} onChange={handleFileInputChange} - accept="image/png,image/jpeg,image/jpg" + accept="image/*" aria-label={ariaLabel} tabIndex={-1} style={{ display: "none" }} diff --git a/apps/client/src/components/common/recent-changes.tsx b/apps/client/src/components/common/recent-changes.tsx index ec531cf4..4e1183d9 100644 --- a/apps/client/src/components/common/recent-changes.tsx +++ b/apps/client/src/components/common/recent-changes.tsx @@ -67,6 +67,7 @@ export default function RecentChanges({ spaceId }: Props) { ( + "ai-chat-window-geom", + null, +); /** * The currently selected chat id. `null` means a fresh (not-yet-created) chat: diff --git a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx index 3d8cf55d..8335bcda 100644 --- a/apps/client/src/features/ai-chat/components/ai-chat-window.tsx +++ b/apps/client/src/features/ai-chat/components/ai-chat-window.tsx @@ -25,6 +25,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { activeAiChatIdAtom, aiChatWindowOpenAtom, + aiChatWindowGeomAtom, aiChatDraftAtom, selectedAiRoleIdAtom, } from "@/features/ai-chat/atoms/ai-chat-atom.ts"; @@ -122,15 +123,13 @@ export default function AiChatWindow() { minimizedRef.current = minimized; const winRef = useRef(null); - // Live window geometry (position + size); initialized lazily on first open so - // it is anchored to the current viewport (top-right corner). Kept in state so - // a user resize survives close/reopen and can be re-clamped to the viewport. - const [geom, setGeom] = useState<{ - left: number; - top: number; - width: number; - height: number; - } | null>(null); + // Live window geometry (position + size); persisted to localStorage so a + // drag/resize survives a full page reload (and close/reopen). `null` means + // "never placed yet" — the layout effect below then computes an initial + // top-right placement anchored to the current viewport, and on restore it is + // re-clamped to the viewport (so a placement saved on a larger screen is not + // left partly off-screen). + const [geom, setGeom] = useAtom(aiChatWindowGeomAtom); // Track whether we are awaiting the id of a just-created (new) chat, so we // can adopt it once the chat list refreshes after the first turn finishes. @@ -390,6 +389,10 @@ export default function AiChatWindow() { useEffect(() => { if (!windowOpen || minimized) return; const el = winRef.current; + // `geom` is in the deps so this re-runs once geometry is settled and the + // window is actually rendered (on the first open `geom` is still null on the + // render that flips windowOpen, so winRef.current is null then — without the + // geom dep the observer would never attach and resizes would not persist). if (!el) return; const ro = new ResizeObserver(() => { const width = el.offsetWidth; @@ -401,7 +404,7 @@ export default function AiChatWindow() { }); ro.observe(el); return () => ro.disconnect(); - }, [windowOpen, minimized]); + }, [windowOpen, minimized, geom !== null]); const startDrag = useCallback((e: React.MouseEvent): void => { // Ignore drags that originate on a button (minimize/close/new chat). diff --git a/apps/client/src/features/auth/components/invite-sign-up-form.tsx b/apps/client/src/features/auth/components/invite-sign-up-form.tsx index f69b9357..dc2d9966 100644 --- a/apps/client/src/features/auth/components/invite-sign-up-form.tsx +++ b/apps/client/src/features/auth/components/invite-sign-up-form.tsx @@ -10,9 +10,12 @@ import { PasswordInput, Box, Stack, + Group, + Text, } from "@mantine/core"; import { zod4Resolver } from "mantine-form-zod-resolver"; -import { useParams, useSearchParams } from "react-router-dom"; +import { Link, useParams, useSearchParams } from "react-router-dom"; +import APP_ROUTE from "@/lib/app-route"; import useAuth from "@/features/auth/hooks/use-auth"; import classes from "@/features/auth/components/auth.module.css"; import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts"; @@ -58,7 +61,27 @@ export function InviteSignUpForm() { } if (isError) { - return
{t("invalid invitation link")}
; + // Styled error with a CTA to login, mirroring the password-reset + // error page and the 404 page (issue #133) + return ( + + + + {t("Invalid invitation link")} + + + + + + + ); } if (!invitation) { diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.close-on-empty.test.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.close-on-empty.test.ts new file mode 100644 index 00000000..c19201ec --- /dev/null +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.close-on-empty.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { getSuggestionItems } from "./menu-items"; + +// The slash-command `allow` callback (slash-command.ts) keeps the popup active +// only while at least one item matches the current query: +// const groups = getSuggestionItems({ query }); +// const hasMatches = Object.values(groups).some((items) => items.length > 0); +// return hasMatches; +// With `allowSpaces: true`, a non-empty query that matches nothing must collapse +// to an empty result so `allow` returns false and the menu closes (instead of +// leaving literal "/todo abc" text behind). These tests pin that contract at the +// `getSuggestionItems` boundary, which is the unit-testable half of `allow`. + +const KEY = "currentUser"; + +function hasMatches(query: string): boolean { + // Mirror the exact predicate used by slash-command.ts `allow`. + const groups = getSuggestionItems({ query }); + return Object.values(groups).some((items) => items.length > 0); +} + +beforeEach(() => { + // Default workspace state: HTML-embed feature OFF (matches production default). + localStorage.setItem(KEY, JSON.stringify({ workspace: { settings: {} } })); +}); + +afterEach(() => { + localStorage.clear(); +}); + +describe("getSuggestionItems — empty-query close behavior (slash `allow`)", () => { + it("keeps the menu allowed for a query that matches items", () => { + expect(hasMatches("h1")).toBe(true); + }); + + it("keeps the menu allowed for a multi-word matching query", () => { + // "Heading 1" is a multi-word title kept alive by allowSpaces. + expect(hasMatches("Heading 1")).toBe(true); + }); + + it("closes the menu (no matches) for a non-empty query that matches nothing", () => { + expect(hasMatches("zzzznomatch")).toBe(false); + }); + + it("closes the menu for a space-bearing non-matching query", () => { + // The exact case the allowSpaces fix targets: "/todo abc" matches nothing. + expect(hasMatches("todo abc")).toBe(false); + }); + + it("returns an empty result object for a no-match query", () => { + expect(getSuggestionItems({ query: "zzzznomatch" })).toEqual({}); + }); + + it("returns a non-empty result for the 'Heading 1' query", () => { + const groups = getSuggestionItems({ query: "Heading 1" }); + const titles = Object.values(groups) + .flat() + .map((item) => item.title); + expect(titles).toContain("Heading 1"); + }); +}); diff --git a/apps/client/src/features/editor/extensions/slash-command.ts b/apps/client/src/features/editor/extensions/slash-command.ts index 339f88ca..947a6d58 100644 --- a/apps/client/src/features/editor/extensions/slash-command.ts +++ b/apps/client/src/features/editor/extensions/slash-command.ts @@ -14,6 +14,10 @@ const Command = Extension.create({ return { suggestion: { char: '/', + // Keep the query alive through spaces so multi-word item labels + // (e.g. "Heading 1", "Math block") match instead of terminating the + // query and leaving literal "/Heading 1" text in the document. + allowSpaces: true, command: ({ editor, range, props }) => { props.command({ editor, range, props }); }, @@ -23,7 +27,22 @@ const Command = Extension.create({ if ($from.parent.type.name === 'codeBlock') { return false; } - return true; + // With `allowSpaces: true` a query that contains a space no longer + // terminates the suggestion on its own, so a space-bearing query that + // matches nothing (e.g. "/todo abc") would otherwise keep an empty + // popup logically active and leave the literal "/todo abc" text in the + // document, only dismissable via Escape. Deactivate the suggestion when + // no item matches the current query: returning false here removes the + // decoration, fires the popup's `onExit`, and lets subsequent keystrokes + // pass through normally — restoring the pre-`allowSpaces` behavior for + // non-matching queries while keeping multi-word matches (e.g. + // "/Heading 1") working. + const query = state.doc.textBetween(range.from + 1, range.to); + const groups = getSuggestionItems({ query }); + const hasMatches = Object.values(groups).some( + (items) => items.length > 0, + ); + return hasMatches; }, } as Partial, }; diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 41b6538e..5aeea3d4 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -113,6 +113,13 @@ export default function PageEditor({ ); const menuContainerRef = useRef(null); const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken(); + // Always holds the latest collab token. The provider effect below runs once + // per pageId, so a handler created inside it would otherwise close over a + // stale `collabQuery`. Reading the ref gives the current token instead. + const collabTokenRef = useRef(undefined); + useEffect(() => { + collabTokenRef.current = collabQuery?.token; + }, [collabQuery?.token]); const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false }); const documentState = useDocumentVisibility(); const { pageSlug } = useParams(); @@ -167,20 +174,33 @@ export default function PageEditor({ } }; const onAuthenticationFailedHandler = () => { - const payload = jwtDecode(collabQuery?.token); - const now = Date.now().valueOf() / 1000; - const isTokenExpired = now >= payload.exp; - if (isTokenExpired) { - refetchCollabToken().then((result) => { - if (result.data?.token) { - socket.disconnect(); - setTimeout(() => { - remote.configuration.token = result.data.token; - socket.connect(); - }, 100); - } - }); + // Read the latest token via the ref (the closure-captured `collabQuery` + // may be stale). Guard the decode: a missing or unparseable token must + // not throw "Invalid token specified" and should trigger a refresh so + // the editor reconnects even when the initial token fetch failed. + const token = collabTokenRef.current; + let needsRefresh = true; // no/unparseable token -> fetch a fresh one and reconnect + if (token) { + try { + // A token that decodes but lacks a numeric `exp` must be treated as + // expired (`Date.now()/1000 >= undefined` is `false`, which would + // otherwise skip the reconnect), so refresh on any missing/non-number exp. + const exp = jwtDecode<{ exp?: number }>(token).exp; + needsRefresh = typeof exp !== "number" || Date.now() / 1000 >= exp; + } catch { + needsRefresh = true; + } } + if (!needsRefresh) return; + refetchCollabToken().then((result) => { + if (result.data?.token) { + socket.disconnect(); + setTimeout(() => { + remote.configuration.token = result.data.token; + socket.connect(); + }, 100); + } + }); }; const remote = new HocuspocusProvider({ websocketProvider: socket, diff --git a/apps/client/src/features/favorite/queries/favorite-query.ts b/apps/client/src/features/favorite/queries/favorite-query.ts index 886f2f5b..51fd7856 100644 --- a/apps/client/src/features/favorite/queries/favorite-query.ts +++ b/apps/client/src/features/favorite/queries/favorite-query.ts @@ -13,6 +13,8 @@ import { ToggleFavoriteParams, } from "../services/favorite-service"; import { FavoriteType } from "../types/favorite.types"; +import { notifications } from "@mantine/notifications"; +import { useTranslation } from "react-i18next"; export function useFavoritesQuery(type?: FavoriteType, spaceId?: string) { return useInfiniteQuery({ @@ -46,6 +48,7 @@ function getEntityId(variables: ToggleFavoriteParams): string | undefined { export function useAddFavoriteMutation() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: (data) => addFavorite(data), @@ -64,12 +67,15 @@ export function useAddFavoriteMutation() { queryClient.invalidateQueries({ queryKey: ["favorites", variables.type], }); + // Notify on success so the action gives visible feedback (issue #128) + notifications.show({ message: t("Added to favorites") }); }, }); } export function useRemoveFavoriteMutation() { const queryClient = useQueryClient(); + const { t } = useTranslation(); return useMutation({ mutationFn: (data) => removeFavorite(data), @@ -87,6 +93,8 @@ export function useRemoveFavoriteMutation() { queryClient.invalidateQueries({ queryKey: ["favorites", variables.type], }); + // Notify on success so the action gives visible feedback (issue #128) + notifications.show({ message: t("Removed from favorites") }); }, }); } diff --git a/apps/client/src/features/group/components/create-group-form.tsx b/apps/client/src/features/group/components/create-group-form.tsx index 3d249bf5..2d8d04c7 100644 --- a/apps/client/src/features/group/components/create-group-form.tsx +++ b/apps/client/src/features/group/components/create-group-form.tsx @@ -8,12 +8,10 @@ import { MultiUserSelect } from "@/features/group/components/multi-user-select.t import { useTranslation } from "react-i18next"; import { zod4Resolver } from 'mantine-form-zod-resolver'; -const formSchema = z.object({ - name: z.string().trim().min(2).max(100), - description: z.string().max(500), -}); - -type FormValues = z.infer; +type FormValues = { + name: string; + description: string; +}; export function CreateGroupForm() { const { t } = useTranslation(); @@ -21,6 +19,18 @@ export function CreateGroupForm() { const [userIds, setUserIds] = useState([]); const navigate = useNavigate(); + // Build the schema with friendly, translated validation messages (issue #130) + const formSchema = z.object({ + name: z + .string() + .trim() + .min(2, t("Group name must be at least 2 characters")) + .max(100, t("Group name must be 100 characters or fewer")), + description: z + .string() + .max(500, t("Description must be 500 characters or fewer")), + }); + const form = useForm({ validate: zod4Resolver(formSchema), initialValues: { diff --git a/apps/client/src/features/group/components/group-members.tsx b/apps/client/src/features/group/components/group-members.tsx index 3bf04b5a..d7139403 100644 --- a/apps/client/src/features/group/components/group-members.tsx +++ b/apps/client/src/features/group/components/group-members.tsx @@ -41,7 +41,7 @@ export default function GroupMembersList() { ), centered: true, - labels: { confirm: t("Delete"), cancel: t("Cancel") }, + labels: { confirm: t("Remove"), cancel: t("Cancel") }, confirmProps: { color: "red" }, onConfirm: () => onRemove(userId), }); diff --git a/apps/client/src/features/home/components/created-by-me.tsx b/apps/client/src/features/home/components/created-by-me.tsx index 65a4273e..c26bde9e 100644 --- a/apps/client/src/features/home/components/created-by-me.tsx +++ b/apps/client/src/features/home/components/created-by-me.tsx @@ -82,6 +82,7 @@ export default function CreatedByMe({ spaceId }: Props) { { notifications.show({ @@ -110,6 +112,8 @@ export function useRemoveLabelMutation(pageId: string | undefined) { queryClient.invalidateQueries({ queryKey: ["workspace-labels"] }); queryClient.invalidateQueries({ queryKey: ["label-pages"] }); queryClient.invalidateQueries({ queryKey: ["label-info"] }); + // Notify on success so the action gives visible feedback (issue #128) + notifications.show({ message: t("Label removed") }); }, onError: () => { notifications.show({ diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index ab7827d6..02cee126 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -102,7 +102,12 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) { {!readOnly && } - {!workspaceSharingDisabled && } + {/* Hide the Share entry point for readers; the toggle inside is inert + without edit permission, so gate it like other edit-only actions + (issue #133) */} + {!readOnly && !workspaceSharingDisabled && ( + + )} } onClick={openMovePageModal} > - {t("Move")} + {t("Move to space")} )} diff --git a/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx b/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx index 54d75f3f..e09fcbe3 100644 --- a/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx +++ b/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx @@ -148,7 +148,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) { variant="subtle" color="gray" className={classes.actionIcon} - aria-label={t("Page menu for {{name}}", { name: node.name || t("untitled") })} + aria-label={t("Page menu for {{name}}", { name: node.name || t("Untitled") })} tabIndex={-1} onClick={(e) => { e.preventDefault(); @@ -199,7 +199,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) { openExportModal(); }} > - {t("Export page")} + {t("Export")} {canEdit && ( @@ -223,7 +223,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) { openMovePageModal(); }} > - {t("Move")} + {t("Move to space")} - {node.name || t("untitled")} + {node.name || t("Untitled")} {node.isTemplate === true && ( @@ -297,7 +297,7 @@ function CreateNode({ variant="subtle" color="gray" className={classes.actionIcon} - aria-label={t("Create subpage of {{name}}", { name: node.name || t("untitled") })} + aria-label={t("Create subpage of {{name}}", { name: node.name || t("Untitled") })} tabIndex={-1} onClick={(e) => { e.preventDefault(); diff --git a/apps/client/src/features/page/tree/components/space-tree.tsx b/apps/client/src/features/page/tree/components/space-tree.tsx index 9a22f280..b2cfb994 100644 --- a/apps/client/src/features/page/tree/components/space-tree.tsx +++ b/apps/client/src/features/page/tree/components/space-tree.tsx @@ -282,7 +282,7 @@ const SpaceTree = forwardRef(function SpaceTree( [], ); const getDragLabel = useCallback( - (n: SpaceTreeNode) => n.name || t("untitled"), + (n: SpaceTreeNode) => n.name || t("Untitled"), [t], ); diff --git a/apps/client/src/features/page/tree/utils/utils.ts b/apps/client/src/features/page/tree/utils/utils.ts index 3ace0650..53d787c6 100644 --- a/apps/client/src/features/page/tree/utils/utils.ts +++ b/apps/client/src/features/page/tree/utils/utils.ts @@ -51,7 +51,7 @@ export function findBreadcrumbPath( ): SpaceTreeNode[] | null { for (const node of tree) { if (!node.name || node.name.trim() === "") { - node.name = "untitled"; + node.name = "Untitled"; } if (node.id === pageId) { diff --git a/apps/client/src/features/search/components/search-spotlight-filters.tsx b/apps/client/src/features/search/components/search-spotlight-filters.tsx index 7ae136c3..ebe8078c 100644 --- a/apps/client/src/features/search/components/search-spotlight-filters.tsx +++ b/apps/client/src/features/search/components/search-spotlight-filters.tsx @@ -107,48 +107,55 @@ export function SearchSpotlightFilters({ - - - - - - {contentTypeOptions.map((option) => ( - - contentType !== option.value && - handleFilterChange("contentType", option.value) - } + {/* Only render the content-type dropdown when there is more than one + option to choose from. With a single option ("Pages") it is a no-op + control, so we hide it instead of showing a dead filter. */} + {contentTypeOptions.length > 1 && ( + + + + {contentType + ? `${t("Type")}: ${contentTypeOptions.find((opt) => opt.value === contentType)?.label || t(contentType === "page" ? "Pages" : "Attachments")}` + : t("Type")} + + + + {contentTypeOptions.map((option) => ( + + contentType !== option.value && + handleFilterChange("contentType", option.value) + } + > + +
+ {option.label} +
+ {contentType === option.value && ( + + )} +
+
+ ))} +
+
+ )} ); } diff --git a/apps/client/src/features/search/components/search-spotlight.tsx b/apps/client/src/features/search/components/search-spotlight.tsx index 5b2a469b..725f8d51 100644 --- a/apps/client/src/features/search/components/search-spotlight.tsx +++ b/apps/client/src/features/search/components/search-spotlight.tsx @@ -90,7 +90,9 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { {query.length > 0 && !isLoading ? resultItems.length === 0 ? t("No results found") - : t("{{count}} results found", { count: resultItems.length }) + : // Singular/plural handling so 1 result is not announced as + // "1 results found". + t("{{count}} result found", { count: resultItems.length }) : ""} diff --git a/apps/client/src/features/share/components/share-modal.tsx b/apps/client/src/features/share/components/share-modal.tsx index 7eae1fa6..5a7d92e6 100644 --- a/apps/client/src/features/share/components/share-modal.tsx +++ b/apps/client/src/features/share/components/share-modal.tsx @@ -192,7 +192,7 @@ export default function ShareModal({ readOnly }: ShareModalProps) { {getPageIcon(share.sharedPage.icon)}
- {share.sharedPage.title || t("untitled")} + {share.sharedPage.title || t("Untitled")}
diff --git a/apps/client/src/features/share/queries/share-query.null-normalization.test.tsx b/apps/client/src/features/share/queries/share-query.null-normalization.test.tsx new file mode 100644 index 00000000..c272d664 --- /dev/null +++ b/apps/client/src/features/share/queries/share-query.null-normalization.test.tsx @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import React from "react"; +import { renderHook, waitFor } from "@testing-library/react"; +import { + QueryClient, + QueryClientProvider, +} from "@tanstack/react-query"; + +// React Query forbids `undefined` as resolved query data ("Query data cannot be +// undefined"). The backend resolves to `undefined` when a page has no share, so +// `useShareForPageQuery` normalizes that absence to `null`: +// queryFn: async () => (await getShareForPage(pageId)) ?? null +// These tests pin that contract: the hook must resolve to `null` (never +// `undefined`) when there is no share, and pass a real share through untouched. + +// Mock the service module so the queryFn calls our stub instead of the network. +vi.mock("@/features/share/services/share-service.ts", () => ({ + getShareForPage: vi.fn(), + // Other named exports referenced by share-query.ts must exist on the mock so + // the module import resolves; they are unused by these tests. + createShare: vi.fn(), + deleteShare: vi.fn(), + getSharedPageTree: vi.fn(), + getShareInfo: vi.fn(), + getSharePageInfo: vi.fn(), + getShares: vi.fn(), + updateShare: vi.fn(), +})); + +import { getShareForPage } from "@/features/share/services/share-service.ts"; +import { useShareForPageQuery } from "@/features/share/queries/share-query.ts"; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return function Wrapper({ children }: { children: React.ReactNode }) { + return ( + {children} + ); + }; +} + +describe("useShareForPageQuery — null normalization", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("normalizes an absent share (undefined) to null", async () => { + vi.mocked(getShareForPage).mockResolvedValue(undefined as any); + + const { result } = renderHook(() => useShareForPageQuery("page-1"), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + // The key assertion: null, never undefined. + expect(result.current.data).toBeNull(); + expect(result.current.data).not.toBeUndefined(); + }); + + it("normalizes an absent share (null) to null", async () => { + vi.mocked(getShareForPage).mockResolvedValue(null as any); + + const { result } = renderHook(() => useShareForPageQuery("page-2"), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toBeNull(); + }); + + it("passes an existing share through unchanged", async () => { + const share = { id: "share-1", pageId: "page-3" } as any; + vi.mocked(getShareForPage).mockResolvedValue(share); + + const { result } = renderHook(() => useShareForPageQuery("page-3"), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(share); + }); +}); diff --git a/apps/client/src/features/share/queries/share-query.ts b/apps/client/src/features/share/queries/share-query.ts index c6e61ff9..30fcabc2 100644 --- a/apps/client/src/features/share/queries/share-query.ts +++ b/apps/client/src/features/share/queries/share-query.ts @@ -65,10 +65,13 @@ export function useSharePageQuery( export function useShareForPageQuery( pageId: string, -): UseQueryResult { +): UseQueryResult { const query = useQuery({ queryKey: ["share-for-page", pageId], - queryFn: () => getShareForPage(pageId), + // React Query forbids `undefined` as resolved data ("Query data cannot be + // undefined"). When no share exists for the page the endpoint resolves to + // undefined, so normalize the absence to `null`. + queryFn: async () => (await getShareForPage(pageId)) ?? null, enabled: !!pageId, staleTime: 60 * 1000, retry: false, diff --git a/apps/client/src/features/user/components/account-name-form.tsx b/apps/client/src/features/user/components/account-name-form.tsx index 70a5b52c..fdf96352 100644 --- a/apps/client/src/features/user/components/account-name-form.tsx +++ b/apps/client/src/features/user/components/account-name-form.tsx @@ -10,17 +10,23 @@ import { TextInput, Button } from "@mantine/core"; import { notifications } from "@mantine/notifications"; import { useTranslation } from "react-i18next"; -const formSchema = z.object({ - name: z.string().min(1).max(40), -}); - -type FormValues = z.infer; +type FormValues = { + name: string; +}; export default function AccountNameForm() { const { t } = useTranslation(); const [isLoading, setIsLoading] = useState(false); const [user, setUser] = useAtom(userAtom); + // Build the schema with friendly, translated validation messages (issue #130) + const formSchema = z.object({ + name: z + .string() + .min(1, t("Name is required")) + .max(40, t("Name must be 40 characters or fewer")), + }); + const form = useForm({ validate: zod4Resolver(formSchema), initialValues: { @@ -34,6 +40,9 @@ export default function AccountNameForm() { try { const updatedUser = await updateUser(data); setUser(updatedUser); + // Reset the dirty baseline so the Save button disables again on a clean + // form right after a successful save. + form.resetDirty(data as FormValues); notifications.show({ message: t("Updated successfully"), }); @@ -57,7 +66,12 @@ export default function AccountNameForm() { variant="filled" {...form.getInputProps("name")} /> - diff --git a/apps/client/src/features/user/components/change-password.tsx b/apps/client/src/features/user/components/change-password.tsx index 9c0b4cb3..d55c7944 100644 --- a/apps/client/src/features/user/components/change-password.tsx +++ b/apps/client/src/features/user/components/change-password.tsx @@ -41,14 +41,10 @@ export default function ChangePassword() { ); } -const formSchema = z.object({ - oldPassword: z - .string({ error: "your current password is required" }) - .min(8), - newPassword: z.string({ error: "New password is required" }).min(8), -}); - -type FormValues = z.infer; +type FormValues = { + oldPassword: string; + newPassword: string; +}; interface ChangePasswordFormProps { onClose?: () => void; @@ -57,6 +53,16 @@ function ChangePasswordForm({ onClose }: ChangePasswordFormProps) { const { t } = useTranslation(); const [isLoading, setIsLoading] = useState(false); + // Build the schema with friendly, translated validation messages (issue #130) + const formSchema = z.object({ + oldPassword: z + .string() + .min(8, t("Password must be at least 8 characters")), + newPassword: z + .string() + .min(8, t("Password must be at least 8 characters")), + }); + const form = useForm({ validate: zod4Resolver(formSchema), initialValues: { diff --git a/apps/client/src/features/workspace/components/members/components/invite-action-menu.tsx b/apps/client/src/features/workspace/components/members/components/invite-action-menu.tsx index e87e95ee..75453ec1 100644 --- a/apps/client/src/features/workspace/components/members/components/invite-action-menu.tsx +++ b/apps/client/src/features/workspace/components/members/components/invite-action-menu.tsx @@ -57,7 +57,7 @@ export default function InviteActionMenu({ invitationId }: Props) { ), centered: true, - labels: { confirm: t("Revoke"), cancel: t("Don't") }, + labels: { confirm: t("Revoke"), cancel: t("Cancel") }, confirmProps: { color: "red" }, onConfirm: onRevoke, }); diff --git a/apps/client/src/features/workspace/components/members/components/members-action-menu.tsx b/apps/client/src/features/workspace/components/members/components/members-action-menu.tsx index f8fd035f..11b19320 100644 --- a/apps/client/src/features/workspace/components/members/components/members-action-menu.tsx +++ b/apps/client/src/features/workspace/components/members/components/members-action-menu.tsx @@ -72,7 +72,7 @@ export default function MemberActionMenu({ ), centered: true, - labels: { confirm: t("Delete"), cancel: t("Don't") }, + labels: { confirm: t("Delete"), cancel: t("Cancel") }, confirmProps: { color: "red" }, onConfirm: onRevoke, }); diff --git a/apps/client/src/features/workspace/components/members/components/workspace-invite-form.tsx b/apps/client/src/features/workspace/components/members/components/workspace-invite-form.tsx index 8deddc7d..1a0a98e1 100644 --- a/apps/client/src/features/workspace/components/members/components/workspace-invite-form.tsx +++ b/apps/client/src/features/workspace/components/members/components/workspace-invite-form.tsx @@ -50,7 +50,7 @@ export function WorkspaceInviteForm({ onClose }: Props) { "Enter valid email addresses separated by comma or space max_50", )} label={t("Invite by email")} - placeholder={t("enter valid emails addresses")} + placeholder={t("enter valid email addresses")} variant="filled" splitChars={[",", " "]} maxDropdownHeight={200} diff --git a/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx b/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx index 63500797..7c7764c8 100644 --- a/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx +++ b/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx @@ -203,8 +203,47 @@ export default function AiProviderSettings() { const { t } = useTranslation(); const { isAdmin } = useUserRole(); + // Reindexing runs as an async background job: the endpoint returns the + // PRE-job counts immediately, so the only way the "Indexed X of Y" counter + // visibly climbs is to keep polling the settings query while the job runs. + // `reindexDeadline` is the timestamp until which we poll (set on reindex + // success); polling stops early once indexed === total. Bounded so a stuck + // job can never poll forever. + const REINDEX_POLL_INTERVAL = 3000; // ms between refetches while indexing + const REINDEX_POLL_CAP_MS = 120000; // ~2 min hard cap + const [reindexDeadline, setReindexDeadline] = useState(null); + // Only admins may read the (masked) AI settings; the server enforces this too. - const { data: settings, isLoading } = useAiSettingsQuery(isAdmin); + const { data: settings, isLoading } = useAiSettingsQuery(isAdmin, (query) => { + if (reindexDeadline === null) return false; + // Past the cap → stop polling (cleared via the effect below too). + if (Date.now() > reindexDeadline) return false; + const data = query.state.data; + // Stop once everything is indexed; otherwise keep polling. + if (data && data.indexedPages >= data.totalPages) return false; + return REINDEX_POLL_INTERVAL; + }); + + // Stop polling once the work is done or the cap is reached. Also clears on + // unmount because the deadline state goes away with the component. + useEffect(() => { + if (reindexDeadline === null) return; + // "Done" matches the refetchInterval stop condition (indexed >= total), + // including an empty workspace (0 >= 0), so the deadline clears promptly + // instead of waiting out the cap. + if (settings && settings.indexedPages >= settings.totalPages) { + setReindexDeadline(null); + return; + } + const msLeft = reindexDeadline - Date.now(); + if (msLeft <= 0) { + setReindexDeadline(null); + return; + } + const timer = setTimeout(() => setReindexDeadline(null), msLeft); + return () => clearTimeout(timer); + }, [reindexDeadline, settings]); + const updateMutation = useUpdateAiSettingsMutation(); const reindexMutation = useReindexAiEmbeddingsMutation(); @@ -877,7 +916,14 @@ export default function AiProviderSettings() { variant="subtle" size="compact-sm" loading={reindexMutation.isPending} - onClick={() => reindexMutation.mutate()} + onClick={() => + reindexMutation.mutate(undefined, { + // Begin bounded polling so the counter climbs as the async + // background job indexes (it does not update on its own). + onSuccess: () => + setReindexDeadline(Date.now() + REINDEX_POLL_CAP_MS), + }) + } > {t("Reindex now")} diff --git a/apps/client/src/features/workspace/queries/ai-settings-query.ts b/apps/client/src/features/workspace/queries/ai-settings-query.ts index 9d4a3508..fe8ce775 100644 --- a/apps/client/src/features/workspace/queries/ai-settings-query.ts +++ b/apps/client/src/features/workspace/queries/ai-settings-query.ts @@ -21,11 +21,20 @@ const aiSettingsKey = ["ai-settings"]; export function useAiSettingsQuery( enabled: boolean = true, + // While reindexing runs as an async background job, the counter only climbs + // if the client keeps refetching. The component passes a refetchInterval + // function that polls until indexed === total or a bounded deadline, then + // returns false to stop. See AiProviderSettings. + refetchInterval?: + | number + | false + | ((query: { state: { data?: IAiSettings } }) => number | false), ): UseQueryResult { return useQuery({ queryKey: aiSettingsKey, queryFn: () => getAiSettings(), enabled, + refetchInterval: refetchInterval as any, }); } diff --git a/apps/client/src/features/workspace/queries/workspace-query.ts b/apps/client/src/features/workspace/queries/workspace-query.ts index 9e1ce514..035d3d85 100644 --- a/apps/client/src/features/workspace/queries/workspace-query.ts +++ b/apps/client/src/features/workspace/queries/workspace-query.ts @@ -84,6 +84,7 @@ export function useDeleteWorkspaceMemberMutation() { } export function useDeactivateWorkspaceMemberMutation() { + const { t } = useTranslation(); const queryClient = useQueryClient(); return useMutation< @@ -98,6 +99,8 @@ export function useDeactivateWorkspaceMemberMutation() { queryClient.invalidateQueries({ queryKey: ["workspaceMembers"], }); + // Notify on success so the action gives visible feedback (issue #128) + notifications.show({ message: t("Member deactivated") }); }, onError: (error) => { const errorMessage = error["response"]?.data?.message; @@ -107,6 +110,7 @@ export function useDeactivateWorkspaceMemberMutation() { } export function useActivateWorkspaceMemberMutation() { + const { t } = useTranslation(); const queryClient = useQueryClient(); return useMutation< @@ -121,6 +125,8 @@ export function useActivateWorkspaceMemberMutation() { queryClient.invalidateQueries({ queryKey: ["workspaceMembers"], }); + // Notify on success so the action gives visible feedback (issue #128) + notifications.show({ message: t("Member activated") }); }, onError: (error) => { const errorMessage = error["response"]?.data?.message; diff --git a/apps/client/src/lib/utils.test.ts b/apps/client/src/lib/utils.test.ts new file mode 100644 index 00000000..82d0c950 --- /dev/null +++ b/apps/client/src/lib/utils.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { computeSpaceSlug } from "@/lib/utils.tsx"; + +// `computeSpaceSlug` derives a space slug that must satisfy the server-side +// @IsAlphanumeric / ^[a-zA-Z0-9]+$ constraint: lowercase the name and strip +// every non-[a-z0-9] character (spaces, punctuation, and non-ascii letters). +// No hyphens, no uppercase, no separators survive. +describe("computeSpaceSlug", () => { + it("strips the space between two words", () => { + expect(computeSpaceSlug("Product Team")).toBe("productteam"); + }); + + it("lowercases and joins a two-word name", () => { + expect(computeSpaceSlug("Hello World")).toBe("helloworld"); + }); + + it("lowercases a single word with no separators", () => { + expect(computeSpaceSlug("SingleWord")).toBe("singleword"); + }); + + it("lowercases an all-caps word and removes the inner space", () => { + expect(computeSpaceSlug("UPPER case")).toBe("uppercase"); + }); + + it("drops non-ascii characters, keeping ascii letters and digits", () => { + // "Привет" (Cyrillic) is stripped entirely; only "a", "b" and "1" remain. + expect(computeSpaceSlug("a b Привет 1")).toBe("ab1"); + }); + + it("returns an empty string for whitespace-only input", () => { + expect(computeSpaceSlug(" ")).toBe(""); + }); + + it("always produces output matching /^[a-z0-9]*$/", () => { + const samples = [ + "Product Team", + "Hello World", + "SingleWord", + "UPPER case", + "a b Привет 1", + " ", + "Mixed-123 !@#", + "Café Münster", + ]; + for (const sample of samples) { + expect(computeSpaceSlug(sample)).toMatch(/^[a-z0-9]*$/); + } + }); +}); diff --git a/apps/client/src/lib/utils.tsx b/apps/client/src/lib/utils.tsx index 79e3ad02..23c9debf 100644 --- a/apps/client/src/lib/utils.tsx +++ b/apps/client/src/lib/utils.tsx @@ -23,15 +23,10 @@ export function extractPageSlugId(slug: string): string { } export const computeSpaceSlug = (name: string) => { - const alphanumericName = name.replace(/[^a-zA-Z0-9\s]/g, ""); - if (alphanumericName.includes(" ")) { - return alphanumericName - .split(" ") - .map((word) => word.charAt(0).toUpperCase()) - .join(""); - } else { - return alphanumericName.toLowerCase(); - } + // Slug is validated as alphanumeric-only (@IsAlphanumeric / ^[a-zA-Z0-9]+$), + // so lowercase the name and strip every non-alphanumeric character (spaces, + // punctuation, unicode). No hyphens or uppercase initials. + return name.toLowerCase().replace(/[^a-z0-9]/g, ""); }; export const formatBytes = (bytes: number): string => { diff --git a/apps/client/src/pages/auth/password-reset.tsx b/apps/client/src/pages/auth/password-reset.tsx index ae7d391b..1112a040 100644 --- a/apps/client/src/pages/auth/password-reset.tsx +++ b/apps/client/src/pages/auth/password-reset.tsx @@ -39,7 +39,7 @@ export default function PasswordReset() { variant="subtle" size="md" > - {t("Goto login page")} + {t("Go to login page")} diff --git a/apps/client/src/pages/favorites/favorites-page.tsx b/apps/client/src/pages/favorites/favorites-page.tsx index 1d28c7ab..4defc839 100644 --- a/apps/client/src/pages/favorites/favorites-page.tsx +++ b/apps/client/src/pages/favorites/favorites-page.tsx @@ -92,6 +92,7 @@ export default function FavoritesPage() { - {`${page?.icon || ""} ${page?.title || t("untitled")}`} + {`${page?.icon || ""} ${page?.title || t("Untitled")}`} diff --git a/apps/server/src/core/auth/auth.controller.ts b/apps/server/src/core/auth/auth.controller.ts index 89bb9e1b..84cdea96 100644 --- a/apps/server/src/core/auth/auth.controller.ts +++ b/apps/server/src/core/auth/auth.controller.ts @@ -14,6 +14,8 @@ import { SkipThrottle, ThrottlerGuard } from '@nestjs/throttler'; import { AI_CHAT_THROTTLER, AUTH_THROTTLER, + PAGE_TEMPLATE_THROTTLER, + PUBLIC_SHARE_AI_THROTTLER, } from '../../integrations/throttle/throttler-names'; import { LoginDto } from './dto/login.dto'; import { AuthService } from './services/auth.service'; @@ -181,7 +183,18 @@ export class AuthController { return this.authService.verifyUserToken(verifyUserTokenDto, workspace.id); } - @SkipThrottle({ [AUTH_THROTTLER]: true }) + // The global ThrottlerGuard applies ALL named throttlers to every route by + // default, so each non-AUTH bucket (AI chat, page template, public-share AI) + // is explicitly skipped here. collab-token is auth-guarded (JwtAuthGuard), + // per-user and client-cached, so those feature buckets are irrelevant to it; + // skipping them avoids spurious 429s when a user opens many pages in a short + // window. The AUTH bucket is skipped too for the same per-user, cached reason. + @SkipThrottle({ + [AUTH_THROTTLER]: true, + [AI_CHAT_THROTTLER]: true, + [PAGE_TEMPLATE_THROTTLER]: true, + [PUBLIC_SHARE_AI_THROTTLER]: true, + }) @UseGuards(JwtAuthGuard) @HttpCode(HttpStatus.OK) @Post('collab-token') diff --git a/apps/server/src/core/workspace/dto/create-workspace.dto.spec.ts b/apps/server/src/core/workspace/dto/create-workspace.dto.spec.ts new file mode 100644 index 00000000..849cafa6 --- /dev/null +++ b/apps/server/src/core/workspace/dto/create-workspace.dto.spec.ts @@ -0,0 +1,81 @@ +import 'reflect-metadata'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { CreateWorkspaceDto } from './create-workspace.dto'; +import { UpdateWorkspaceDto } from './update-workspace.dto'; + +// API-boundary validation for the workspace `name` field. The name is: +// - required, 1..64 chars (MinLength/MaxLength), trimmed on input; +// - rejected by @NoUrls when it contains a URL or a bare domain name. +// UpdateWorkspaceDto extends CreateWorkspaceDto via PartialType, so `name` +// stays optional there but inherits the same constraints when present. + +async function validateCreate(payload: Record) { + const dto = plainToInstance(CreateWorkspaceDto, payload); + return validate(dto as object); +} + +async function validateUpdate(payload: Record) { + const dto = plainToInstance(UpdateWorkspaceDto, payload); + return validate(dto as object); +} + +function hasError(errors: any[], property: string, constraint?: string) { + const err = errors.find((e) => e.property === property); + if (!err) return false; + if (!constraint) return true; + return Object.keys(err.constraints ?? {}).includes(constraint); +} + +describe('CreateWorkspaceDto.name validation', () => { + it('accepts a plain workspace name', async () => { + const errors = await validateCreate({ name: 'My Workspace' }); + expect(hasError(errors, 'name')).toBe(false); + }); + + it('rejects a name containing a URL with the noUrls error', async () => { + const errors = await validateCreate({ + name: 'Visit https://evil.com now', + }); + expect(hasError(errors, 'name', 'noUrls')).toBe(true); + }); + + it('rejects a name containing a bare domain with the noUrls error', async () => { + const errors = await validateCreate({ name: 'evil.com workspace' }); + expect(hasError(errors, 'name', 'noUrls')).toBe(true); + }); + + it('rejects an empty name with a minLength error', async () => { + const errors = await validateCreate({ name: '' }); + expect(hasError(errors, 'name', 'minLength')).toBe(true); + }); + + it('accepts exactly 64 characters', async () => { + const errors = await validateCreate({ name: 'a'.repeat(64) }); + expect(hasError(errors, 'name')).toBe(false); + }); + + it('rejects 65 characters with a maxLength error', async () => { + const errors = await validateCreate({ name: 'a'.repeat(65) }); + expect(hasError(errors, 'name', 'maxLength')).toBe(true); + }); +}); + +describe('UpdateWorkspaceDto.name validation (inherited)', () => { + it('accepts a plain workspace name', async () => { + const errors = await validateUpdate({ name: 'My Workspace' }); + expect(hasError(errors, 'name')).toBe(false); + }); + + it('rejects a name containing a URL with the noUrls error', async () => { + const errors = await validateUpdate({ + name: 'Visit https://evil.com now', + }); + expect(hasError(errors, 'name', 'noUrls')).toBe(true); + }); + + it('accepts an omitted name (optional via PartialType)', async () => { + const errors = await validateUpdate({}); + expect(hasError(errors, 'name')).toBe(false); + }); +}); diff --git a/apps/server/src/core/workspace/dto/create-workspace.dto.ts b/apps/server/src/core/workspace/dto/create-workspace.dto.ts index 178f1029..7a42dfb8 100644 --- a/apps/server/src/core/workspace/dto/create-workspace.dto.ts +++ b/apps/server/src/core/workspace/dto/create-workspace.dto.ts @@ -6,11 +6,13 @@ import { MinLength, } from 'class-validator'; import { Transform, TransformFnParams } from 'class-transformer'; +import { NoUrls } from '../../../common/validators/no-urls.validator'; export class CreateWorkspaceDto { @MinLength(1) @MaxLength(64) @IsString() + @NoUrls() @Transform(({ value }: TransformFnParams) => value?.trim()) name: string; diff --git a/apps/server/src/integrations/ai/ai-settings.controller.ts b/apps/server/src/integrations/ai/ai-settings.controller.ts index fdce645e..d8f2149c 100644 --- a/apps/server/src/integrations/ai/ai-settings.controller.ts +++ b/apps/server/src/integrations/ai/ai-settings.controller.ts @@ -86,7 +86,9 @@ export class AiSettingsController { ) { this.assertAdmin(user, workspace); await this.aiSettingsService.reindex(workspace.id); - // Return refreshed masked settings so the client can update the counter. + // Indexing runs as an async background job, so these masked settings carry + // the PRE-job counts (the indexed total has not climbed yet). The client + // polls this endpoint's GET counterpart to watch the counter advance. return this.aiSettingsService.getMasked(workspace.id); } } diff --git a/apps/server/src/integrations/ai/ai-stt-not-configured.exception.ts b/apps/server/src/integrations/ai/ai-stt-not-configured.exception.ts index e7bbc8ae..5e644d74 100644 --- a/apps/server/src/integrations/ai/ai-stt-not-configured.exception.ts +++ b/apps/server/src/integrations/ai/ai-stt-not-configured.exception.ts @@ -8,6 +8,8 @@ import { ServiceUnavailableException } from '@nestjs/common'; */ export class AiSttNotConfiguredException extends ServiceUnavailableException { constructor() { - super('AI STT model not configured'); + // User-facing copy: the client surfaces this 503 message verbatim in the + // dictation toast, so keep it consistent with the client's fallback copy. + super('Voice dictation is not configured'); } }