fix(qa): resolve QA-pass issues #122–#134 #135

Merged
vvzvlad merged 2 commits from fix/qa-issues-122-134 into develop 2026-06-22 21:07:19 +03:00
45 changed files with 657 additions and 124 deletions
Showing only changes of commit 9e1d057878 - Show all commits

View File

@@ -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",

View File

@@ -42,6 +42,22 @@ 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).
const acceptedTypes = ["image/png", "image/jpeg", "image/jpg"];
if (!acceptedTypes.includes(file.type)) {
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 +74,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({

View File

@@ -67,6 +67,7 @@ export default function RecentChanges({ spaceId }: Props) {
<Badge
color={getInitialsColor(page?.space.name)}
variant="light"
tt="none"
component={Link}
to={getSpaceUrl(page?.space.slug)}
style={{ cursor: "pointer" }}

View File

@@ -9,8 +9,10 @@ export function IconColumns4({ size = 24, stroke = 2 }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={rem(size)}
height={rem(size)}
// rem(size) returns a `calc(...)` string, which is invalid for the raw
// SVG width/height length attributes ("Expected length, calc(...)"). Pass
// it via CSS style instead (matching the other icon components).
style={{ width: rem(size), height: rem(size) }}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"

View File

@@ -9,8 +9,10 @@ export function IconColumns5({ size = 24, stroke = 2 }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={rem(size)}
height={rem(size)}
// rem(size) returns a `calc(...)` string, which is invalid for the raw
// SVG width/height length attributes ("Expected length, calc(...)"). Pass
// it via CSS style instead (matching the other icon components).
style={{ width: rem(size), height: rem(size) }}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"

View File

@@ -1,4 +1,22 @@
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";
/**
* Persisted floating AI chat window geometry (position + size). Held in
* localStorage so a drag/resize survives a full page reload. `null` means
* "never placed yet" — the window then computes an initial top-right placement.
* On restore the value is clamped to the current viewport (see AiChatWindow).
*/
export type AiChatWindowGeom = {
left: number;
top: number;
width: number;
height: number;
};
export const aiChatWindowGeomAtom = atomWithStorage<AiChatWindowGeom | null>(
"ai-chat-window-geom",
null,
);
/**
* The currently selected chat id. `null` means a fresh (not-yet-created) chat:

View File

@@ -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<HTMLDivElement>(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).

View File

@@ -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 <div>{t("invalid invitation link")}</div>;
// Styled error with a CTA to login, mirroring the password-reset
// error page and the 404 page (issue #133)
return (
<AuthLayout>
<Container my={40}>
<Text size="lg" ta="center">
{t("Invalid invitation link")}
</Text>
<Group justify="center">
<Button
component={Link}
to={APP_ROUTE.AUTH.LOGIN}
variant="subtle"
size="md"
>
{t("Go to login page")}
</Button>
</Group>
</Container>
</AuthLayout>
);
}
if (!invitation) {

View File

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

View File

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

View File

@@ -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<string | undefined>(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,

View File

@@ -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<void, Error, ToggleFavoriteParams>({
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<void, Error, ToggleFavoriteParams>({
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") });
},
});
}

View File

@@ -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<typeof formSchema>;
type FormValues = {
name: string;
description: string;
};
export function CreateGroupForm() {
const { t } = useTranslation();
@@ -21,6 +19,18 @@ export function CreateGroupForm() {
const [userIds, setUserIds] = useState<string[]>([]);
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<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: {

View File

@@ -41,7 +41,7 @@ export default function GroupMembersList() {
</Text>
),
centered: true,
labels: { confirm: t("Delete"), cancel: t("Cancel") },
labels: { confirm: t("Remove"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => onRemove(userId),
});

View File

@@ -82,6 +82,7 @@ export default function CreatedByMe({ spaceId }: Props) {
<Badge
color={getInitialsColor(page?.space.name)}
variant="light"
tt="none"
component={Link}
to={getSpaceUrl(page?.space.slug)}
style={{ cursor: "pointer" }}

View File

@@ -84,6 +84,7 @@ export default function FavoritesPages({ spaceId }: Props) {
<Badge
color={getInitialsColor(fav.space.name)}
variant="light"
tt="none"
component={Link}
to={getSpaceUrl(fav.space.slug)}
style={{ cursor: "pointer" }}

View File

@@ -78,6 +78,8 @@ export function useAddLabelsMutation(pageId: string | undefined) {
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 added") });
},
onError: (error: any) => {
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({

View File

@@ -102,7 +102,12 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
{!readOnly && <PageEditModeToggle size="xs" />}
{!workspaceSharingDisabled && <ShareModal readOnly={readOnly ?? false} />}
{/* 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 && (
<ShareModal readOnly={false} />
)}
<Tooltip label={t("Comments")} openDelay={250} withArrow>
<ActionIcon
@@ -286,7 +291,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
leftSection={<IconArrowRight size={16} />}
onClick={openMovePageModal}
>
{t("Move")}
{t("Move to space")}
</Menu.Item>
)}

View File

@@ -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")}
</Menu.Item>
{canEdit && (
@@ -223,7 +223,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
openMovePageModal();
}}
>
{t("Move")}
{t("Move to space")}
</Menu.Item>
<Menu.Item

View File

@@ -170,7 +170,7 @@ export function SpaceTreeRow({
/>
</div>
<span className={classes.text}>{node.name || t("untitled")}</span>
<span className={classes.text}>{node.name || t("Untitled")}</span>
{node.isTemplate === true && (
<Tooltip label={t("Template")} withArrow>
@@ -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();

View File

@@ -282,7 +282,7 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
[],
);
const getDragLabel = useCallback(
(n: SpaceTreeNode) => n.name || t("untitled"),
(n: SpaceTreeNode) => n.name || t("Untitled"),
[t],
);

View File

@@ -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) {

View File

@@ -107,48 +107,55 @@ export function SearchSpotlightFilters({
</Button>
</SpaceFilterMenu>
<Menu
shadow="md"
width={220}
position="bottom-start"
zIndex={getDefaultZIndex("max")}
>
<Menu.Target>
<Button
variant="subtle"
color="gray"
size="sm"
rightSection={<IconChevronDown size={14} />}
leftSection={<IconFileDescription size={16} />}
className={classes.filterButton}
fw={500}
>
{contentType
? `${t("Type")}: ${contentTypeOptions.find((opt) => opt.value === contentType)?.label || t(contentType === "page" ? "Pages" : "Attachments")}`
: t("Type")}
</Button>
</Menu.Target>
<Menu.Dropdown>
{contentTypeOptions.map((option) => (
<Menu.Item
key={option.value}
component={RadioMenuItem}
aria-checked={contentType === option.value}
onClick={() =>
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 && (
<Menu
shadow="md"
width={220}
position="bottom-start"
zIndex={getDefaultZIndex("max")}
>
<Menu.Target>
<Button
variant="subtle"
color="gray"
size="sm"
rightSection={<IconChevronDown size={14} />}
leftSection={<IconFileDescription size={16} />}
className={classes.filterButton}
fw={500}
>
<Group flex="1" gap="xs">
<div>
<Text size="sm">{option.label}</Text>
</div>
{contentType === option.value && <IconCheck size={20} aria-hidden />}
</Group>
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
{contentType
? `${t("Type")}: ${contentTypeOptions.find((opt) => opt.value === contentType)?.label || t(contentType === "page" ? "Pages" : "Attachments")}`
: t("Type")}
</Button>
</Menu.Target>
<Menu.Dropdown>
{contentTypeOptions.map((option) => (
<Menu.Item
key={option.value}
component={RadioMenuItem}
aria-checked={contentType === option.value}
onClick={() =>
contentType !== option.value &&
handleFilterChange("contentType", option.value)
}
>
<Group flex="1" gap="xs">
<div>
<Text size="sm">{option.label}</Text>
</div>
{contentType === option.value && (
<IconCheck size={20} aria-hidden />
)}
</Group>
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
</div>
);
}

View File

@@ -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 })
: ""}
</VisuallyHidden>

View File

@@ -192,7 +192,7 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
{getPageIcon(share.sharedPage.icon)}
<div className={classes.shareLinkText}>
<Text fz="sm" fw={500} lineClamp={1}>
{share.sharedPage.title || t("untitled")}
{share.sharedPage.title || t("Untitled")}
</Text>
</div>
</Group>

View File

@@ -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 (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
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);
});
});

View File

@@ -65,10 +65,13 @@ export function useSharePageQuery(
export function useShareForPageQuery(
pageId: string,
): UseQueryResult<IShareForPage, Error> {
): UseQueryResult<IShareForPage | null, Error> {
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,

View File

@@ -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<typeof formSchema>;
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<FormValues>({
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")}
/>
<Button type="submit" mt="sm" disabled={isLoading} loading={isLoading}>
<Button
type="submit"
mt="sm"
disabled={isLoading || !form.isDirty()}
loading={isLoading}
>
{t("Save")}
</Button>
</form>

View File

@@ -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<typeof formSchema>;
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<FormValues>({
validate: zod4Resolver(formSchema),
initialValues: {

View File

@@ -57,7 +57,7 @@ export default function InviteActionMenu({ invitationId }: Props) {
</Text>
),
centered: true,
labels: { confirm: t("Revoke"), cancel: t("Don't") },
labels: { confirm: t("Revoke"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: onRevoke,
});

View File

@@ -72,7 +72,7 @@ export default function MemberActionMenu({
</Text>
),
centered: true,
labels: { confirm: t("Delete"), cancel: t("Don't") },
labels: { confirm: t("Delete"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: onRevoke,
});

View File

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

View File

@@ -203,8 +203,48 @@ 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<number | null>(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;
if (
settings &&
settings.totalPages > 0 &&
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 +917,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")}
</Button>

View File

@@ -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<IAiSettings, Error> {
return useQuery({
queryKey: aiSettingsKey,
queryFn: () => getAiSettings(),
enabled,
refetchInterval: refetchInterval as any,
});
}

View File

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

View File

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

View File

@@ -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 => {

View File

@@ -39,7 +39,7 @@ export default function PasswordReset() {
variant="subtle"
size="md"
>
{t("Goto login page")}
{t("Go to login page")}
</Button>
</Group>
</Container>

View File

@@ -92,6 +92,7 @@ export default function FavoritesPage() {
<Badge
color={getInitialsColor(fav.space.name)}
variant="light"
tt="none"
component={Link}
to={getSpaceUrl(fav.space.slug)}
style={{ cursor: "pointer" }}

View File

@@ -94,7 +94,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
page && (
<div>
<Helmet>
<title>{`${page?.icon || ""} ${page?.title || t("untitled")}`}</title>
<title>{`${page?.icon || ""} ${page?.title || t("Untitled")}`}</title>
</Helmet>
<MemoizedPageHeader readOnly={!canEdit} />

View File

@@ -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')

View File

@@ -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<string, unknown>) {
const dto = plainToInstance(CreateWorkspaceDto, payload);
return validate(dto as object);
}
async function validateUpdate(payload: Record<string, unknown>) {
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);
});
});

View File

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

View File

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

View File

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