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

Batch of fixes from the automated QA pass on develop. Each was reproduced and
then verified fixed live (browser/curl); logic-bearing fixes have unit tests.

Functional bugs:
- #122 collab-token was capped by the anonymous public-share-AI throttler (5/min);
  skip all non-AUTH named throttlers on this auth-guarded, client-cached route.
- #123 editor onAuthenticationFailed threw `jwtDecode(undefined)` and never
  reconnected; read the token via a ref, guard the decode (incl. missing exp),
  and refetch+reconnect on any auth failure.
- #124 a slash command containing a space ("/Heading 1") inserted literal text;
  enable allowSpaces and close the menu when the query matches no items.
- #125 space slug auto-gen produced uppercase initials for multi-word names;
  computeSpaceSlug now yields a lowercase alphanumeric slug.
- #126 AI chat window position/size now persisted (atomWithStorage) across reload;
  also fixes a latent ResizeObserver-attach bug on first open.
- #127 workspace name update accepted URLs; add @NoUrls (parity with setup).
- #132 icon-columns 4/5 passed calc() into SVG width/height attrs (console spam);
  size via style. share-for-page query returns null instead of undefined.
- #134 "Reindex now" counter looked stuck: reindex runs async; the client now
  polls coverage (bounded) so the counter climbs live; misleading server comment
  reworded.

UX / consistency:
- #128 add success toasts to favorite/label/avatar/member-(de)activate.
- #129 "1 result found" pluralization; hide the single-option Type filter.
- #130 replace raw Zod strings with friendly messages (name/password/group).
- #131 unify "Untitled" casing in tree/breadcrumb/tab; stop force-uppercasing
  space-name chips; fix confirm-dialog labels (Cancel / Remove), invite
  placeholder typo, Export/Move-to-space labels.
- #133 disable profile Save when clean; toast on unsupported avatar image;
  style the invalid-invitation page with a CTA; hide Share for read-only users;
  align the dictation "not configured" message; "Go to login page" typo.

Tests: computeSpaceSlug, workspace-name NoUrls DTO, share-query null
normalization, slash getSuggestionItems empty-close.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-22 20:47:40 +03:00
parent f6a4df1b08
commit 9e1d057878
45 changed files with 657 additions and 124 deletions

View File

@@ -420,6 +420,8 @@
"{{count}} command available_other": "{{count}} commands available", "{{count}} command available_other": "{{count}} commands available",
"{{count}} result available_one": "1 result available", "{{count}} result available_one": "1 result available",
"{{count}} result available_other": "{{count}} results 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", "Equal columns": "Equal columns",
"Left sidebar": "Left sidebar", "Left sidebar": "Left sidebar",
"Right sidebar": "Right sidebar", "Right sidebar": "Right sidebar",
@@ -1127,6 +1129,18 @@
"Removed from favorites": "Removed from favorites", "Removed from favorites": "Removed from favorites",
"Added {{name}} to favorites": "Added {{name}} to favorites", "Added {{name}} to favorites": "Added {{name}} to favorites",
"Removed {{name}} from favorites": "Removed {{name}} from 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}}", "Page menu for {{name}}": "Page menu for {{name}}",
"Create subpage of {{name}}": "Create subpage of {{name}}", "Create subpage of {{name}}": "Create subpage of {{name}}",
"AI chat": "AI chat", "AI chat": "AI chat",

View File

@@ -42,6 +42,22 @@ export default function AvatarUploader({
return; 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) // Validate file size (max 10MB)
const maxSizeInBytes = 10 * 1024 * 1024; const maxSizeInBytes = 10 * 1024 * 1024;
if (file.size > maxSizeInBytes) { if (file.size > maxSizeInBytes) {
@@ -58,6 +74,8 @@ export default function AvatarUploader({
try { try {
await onUpload(file); await onUpload(file);
// Notify on success so the upload gives visible feedback (issue #128)
notifications.show({ message: t("Image updated") });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
notifications.show({ notifications.show({

View File

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

View File

@@ -9,8 +9,10 @@ export function IconColumns4({ size = 24, stroke = 2 }: Props) {
return ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width={rem(size)} // rem(size) returns a `calc(...)` string, which is invalid for the raw
height={rem(size)} // 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" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"

View File

@@ -9,8 +9,10 @@ export function IconColumns5({ size = 24, stroke = 2 }: Props) {
return ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width={rem(size)} // rem(size) returns a `calc(...)` string, which is invalid for the raw
height={rem(size)} // 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" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"

View File

@@ -1,4 +1,22 @@
import { atom } from "jotai"; 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: * 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 { import {
activeAiChatIdAtom, activeAiChatIdAtom,
aiChatWindowOpenAtom, aiChatWindowOpenAtom,
aiChatWindowGeomAtom,
aiChatDraftAtom, aiChatDraftAtom,
selectedAiRoleIdAtom, selectedAiRoleIdAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts"; } from "@/features/ai-chat/atoms/ai-chat-atom.ts";
@@ -122,15 +123,13 @@ export default function AiChatWindow() {
minimizedRef.current = minimized; minimizedRef.current = minimized;
const winRef = useRef<HTMLDivElement>(null); const winRef = useRef<HTMLDivElement>(null);
// Live window geometry (position + size); initialized lazily on first open so // Live window geometry (position + size); persisted to localStorage so a
// it is anchored to the current viewport (top-right corner). Kept in state so // drag/resize survives a full page reload (and close/reopen). `null` means
// a user resize survives close/reopen and can be re-clamped to the viewport. // "never placed yet" — the layout effect below then computes an initial
const [geom, setGeom] = useState<{ // top-right placement anchored to the current viewport, and on restore it is
left: number; // re-clamped to the viewport (so a placement saved on a larger screen is not
top: number; // left partly off-screen).
width: number; const [geom, setGeom] = useAtom(aiChatWindowGeomAtom);
height: number;
} | null>(null);
// Track whether we are awaiting the id of a just-created (new) chat, so we // 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. // can adopt it once the chat list refreshes after the first turn finishes.
@@ -390,6 +389,10 @@ export default function AiChatWindow() {
useEffect(() => { useEffect(() => {
if (!windowOpen || minimized) return; if (!windowOpen || minimized) return;
const el = winRef.current; 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; if (!el) return;
const ro = new ResizeObserver(() => { const ro = new ResizeObserver(() => {
const width = el.offsetWidth; const width = el.offsetWidth;
@@ -401,7 +404,7 @@ export default function AiChatWindow() {
}); });
ro.observe(el); ro.observe(el);
return () => ro.disconnect(); return () => ro.disconnect();
}, [windowOpen, minimized]); }, [windowOpen, minimized, geom !== null]);
const startDrag = useCallback((e: React.MouseEvent): void => { const startDrag = useCallback((e: React.MouseEvent): void => {
// Ignore drags that originate on a button (minimize/close/new chat). // Ignore drags that originate on a button (minimize/close/new chat).

View File

@@ -10,9 +10,12 @@ import {
PasswordInput, PasswordInput,
Box, Box,
Stack, Stack,
Group,
Text,
} from "@mantine/core"; } from "@mantine/core";
import { zod4Resolver } from "mantine-form-zod-resolver"; 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 useAuth from "@/features/auth/hooks/use-auth";
import classes from "@/features/auth/components/auth.module.css"; import classes from "@/features/auth/components/auth.module.css";
import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts"; import { useGetInvitationQuery } from "@/features/workspace/queries/workspace-query.ts";
@@ -58,7 +61,27 @@ export function InviteSignUpForm() {
} }
if (isError) { 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) { 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 { return {
suggestion: { suggestion: {
char: '/', 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 }) => { command: ({ editor, range, props }) => {
props.command({ editor, range, props }); props.command({ editor, range, props });
}, },
@@ -23,7 +27,22 @@ const Command = Extension.create({
if ($from.parent.type.name === 'codeBlock') { if ($from.parent.type.name === 'codeBlock') {
return false; 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>, } as Partial<SuggestionOptions>,
}; };

View File

@@ -113,6 +113,13 @@ export default function PageEditor({
); );
const menuContainerRef = useRef(null); const menuContainerRef = useRef(null);
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken(); 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 { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
const documentState = useDocumentVisibility(); const documentState = useDocumentVisibility();
const { pageSlug } = useParams(); const { pageSlug } = useParams();
@@ -167,20 +174,33 @@ export default function PageEditor({
} }
}; };
const onAuthenticationFailedHandler = () => { const onAuthenticationFailedHandler = () => {
const payload = jwtDecode(collabQuery?.token); // Read the latest token via the ref (the closure-captured `collabQuery`
const now = Date.now().valueOf() / 1000; // may be stale). Guard the decode: a missing or unparseable token must
const isTokenExpired = now >= payload.exp; // not throw "Invalid token specified" and should trigger a refresh so
if (isTokenExpired) { // the editor reconnects even when the initial token fetch failed.
refetchCollabToken().then((result) => { const token = collabTokenRef.current;
if (result.data?.token) { let needsRefresh = true; // no/unparseable token -> fetch a fresh one and reconnect
socket.disconnect(); if (token) {
setTimeout(() => { try {
remote.configuration.token = result.data.token; // A token that decodes but lacks a numeric `exp` must be treated as
socket.connect(); // expired (`Date.now()/1000 >= undefined` is `false`, which would
}, 100); // 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({ const remote = new HocuspocusProvider({
websocketProvider: socket, websocketProvider: socket,

View File

@@ -13,6 +13,8 @@ import {
ToggleFavoriteParams, ToggleFavoriteParams,
} from "../services/favorite-service"; } from "../services/favorite-service";
import { FavoriteType } from "../types/favorite.types"; import { FavoriteType } from "../types/favorite.types";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function useFavoritesQuery(type?: FavoriteType, spaceId?: string) { export function useFavoritesQuery(type?: FavoriteType, spaceId?: string) {
return useInfiniteQuery({ return useInfiniteQuery({
@@ -46,6 +48,7 @@ function getEntityId(variables: ToggleFavoriteParams): string | undefined {
export function useAddFavoriteMutation() { export function useAddFavoriteMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, ToggleFavoriteParams>({ return useMutation<void, Error, ToggleFavoriteParams>({
mutationFn: (data) => addFavorite(data), mutationFn: (data) => addFavorite(data),
@@ -64,12 +67,15 @@ export function useAddFavoriteMutation() {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["favorites", variables.type], 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() { export function useRemoveFavoriteMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, ToggleFavoriteParams>({ return useMutation<void, Error, ToggleFavoriteParams>({
mutationFn: (data) => removeFavorite(data), mutationFn: (data) => removeFavorite(data),
@@ -87,6 +93,8 @@ export function useRemoveFavoriteMutation() {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["favorites", variables.type], 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 { useTranslation } from "react-i18next";
import { zod4Resolver } from 'mantine-form-zod-resolver'; import { zod4Resolver } from 'mantine-form-zod-resolver';
const formSchema = z.object({ type FormValues = {
name: z.string().trim().min(2).max(100), name: string;
description: z.string().max(500), description: string;
}); };
type FormValues = z.infer<typeof formSchema>;
export function CreateGroupForm() { export function CreateGroupForm() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -21,6 +19,18 @@ export function CreateGroupForm() {
const [userIds, setUserIds] = useState<string[]>([]); const [userIds, setUserIds] = useState<string[]>([]);
const navigate = useNavigate(); 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>({ const form = useForm<FormValues>({
validate: zod4Resolver(formSchema), validate: zod4Resolver(formSchema),
initialValues: { initialValues: {

View File

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

View File

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

View File

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

View File

@@ -78,6 +78,8 @@ export function useAddLabelsMutation(pageId: string | undefined) {
queryClient.invalidateQueries({ queryKey: ["label-pages"] }); queryClient.invalidateQueries({ queryKey: ["label-pages"] });
queryClient.invalidateQueries({ queryKey: ["label-info"] }); 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) => { onError: (error: any) => {
notifications.show({ notifications.show({
@@ -110,6 +112,8 @@ export function useRemoveLabelMutation(pageId: string | undefined) {
queryClient.invalidateQueries({ queryKey: ["workspace-labels"] }); queryClient.invalidateQueries({ queryKey: ["workspace-labels"] });
queryClient.invalidateQueries({ queryKey: ["label-pages"] }); queryClient.invalidateQueries({ queryKey: ["label-pages"] });
queryClient.invalidateQueries({ queryKey: ["label-info"] }); queryClient.invalidateQueries({ queryKey: ["label-info"] });
// Notify on success so the action gives visible feedback (issue #128)
notifications.show({ message: t("Label removed") });
}, },
onError: () => { onError: () => {
notifications.show({ notifications.show({

View File

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

View File

@@ -148,7 +148,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
variant="subtle" variant="subtle"
color="gray" color="gray"
className={classes.actionIcon} 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} tabIndex={-1}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
@@ -199,7 +199,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
openExportModal(); openExportModal();
}} }}
> >
{t("Export page")} {t("Export")}
</Menu.Item> </Menu.Item>
{canEdit && ( {canEdit && (
@@ -223,7 +223,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
openMovePageModal(); openMovePageModal();
}} }}
> >
{t("Move")} {t("Move to space")}
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item

View File

@@ -170,7 +170,7 @@ export function SpaceTreeRow({
/> />
</div> </div>
<span className={classes.text}>{node.name || t("untitled")}</span> <span className={classes.text}>{node.name || t("Untitled")}</span>
{node.isTemplate === true && ( {node.isTemplate === true && (
<Tooltip label={t("Template")} withArrow> <Tooltip label={t("Template")} withArrow>
@@ -297,7 +297,7 @@ function CreateNode({
variant="subtle" variant="subtle"
color="gray" color="gray"
className={classes.actionIcon} 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} tabIndex={-1}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();

View File

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

View File

@@ -51,7 +51,7 @@ export function findBreadcrumbPath(
): SpaceTreeNode[] | null { ): SpaceTreeNode[] | null {
for (const node of tree) { for (const node of tree) {
if (!node.name || node.name.trim() === "") { if (!node.name || node.name.trim() === "") {
node.name = "untitled"; node.name = "Untitled";
} }
if (node.id === pageId) { if (node.id === pageId) {

View File

@@ -107,48 +107,55 @@ export function SearchSpotlightFilters({
</Button> </Button>
</SpaceFilterMenu> </SpaceFilterMenu>
<Menu {/* Only render the content-type dropdown when there is more than one
shadow="md" option to choose from. With a single option ("Pages") it is a no-op
width={220} control, so we hide it instead of showing a dead filter. */}
position="bottom-start" {contentTypeOptions.length > 1 && (
zIndex={getDefaultZIndex("max")} <Menu
> shadow="md"
<Menu.Target> width={220}
<Button position="bottom-start"
variant="subtle" zIndex={getDefaultZIndex("max")}
color="gray" >
size="sm" <Menu.Target>
rightSection={<IconChevronDown size={14} />} <Button
leftSection={<IconFileDescription size={16} />} variant="subtle"
className={classes.filterButton} color="gray"
fw={500} size="sm"
> rightSection={<IconChevronDown size={14} />}
{contentType leftSection={<IconFileDescription size={16} />}
? `${t("Type")}: ${contentTypeOptions.find((opt) => opt.value === contentType)?.label || t(contentType === "page" ? "Pages" : "Attachments")}` className={classes.filterButton}
: t("Type")} fw={500}
</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"> {contentType
<div> ? `${t("Type")}: ${contentTypeOptions.find((opt) => opt.value === contentType)?.label || t(contentType === "page" ? "Pages" : "Attachments")}`
<Text size="sm">{option.label}</Text> : t("Type")}
</div> </Button>
{contentType === option.value && <IconCheck size={20} aria-hidden />} </Menu.Target>
</Group> <Menu.Dropdown>
</Menu.Item> {contentTypeOptions.map((option) => (
))} <Menu.Item
</Menu.Dropdown> key={option.value}
</Menu> 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> </div>
); );
} }

View File

@@ -90,7 +90,9 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
{query.length > 0 && !isLoading {query.length > 0 && !isLoading
? resultItems.length === 0 ? resultItems.length === 0
? t("No results found") ? 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> </VisuallyHidden>

View File

@@ -192,7 +192,7 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
{getPageIcon(share.sharedPage.icon)} {getPageIcon(share.sharedPage.icon)}
<div className={classes.shareLinkText}> <div className={classes.shareLinkText}>
<Text fz="sm" fw={500} lineClamp={1}> <Text fz="sm" fw={500} lineClamp={1}>
{share.sharedPage.title || t("untitled")} {share.sharedPage.title || t("Untitled")}
</Text> </Text>
</div> </div>
</Group> </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( export function useShareForPageQuery(
pageId: string, pageId: string,
): UseQueryResult<IShareForPage, Error> { ): UseQueryResult<IShareForPage | null, Error> {
const query = useQuery({ const query = useQuery({
queryKey: ["share-for-page", pageId], 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, enabled: !!pageId,
staleTime: 60 * 1000, staleTime: 60 * 1000,
retry: false, retry: false,

View File

@@ -10,17 +10,23 @@ import { TextInput, Button } from "@mantine/core";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
const formSchema = z.object({ type FormValues = {
name: z.string().min(1).max(40), name: string;
}); };
type FormValues = z.infer<typeof formSchema>;
export default function AccountNameForm() { export default function AccountNameForm() {
const { t } = useTranslation(); const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [user, setUser] = useAtom(userAtom); 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>({ const form = useForm<FormValues>({
validate: zod4Resolver(formSchema), validate: zod4Resolver(formSchema),
initialValues: { initialValues: {
@@ -34,6 +40,9 @@ export default function AccountNameForm() {
try { try {
const updatedUser = await updateUser(data); const updatedUser = await updateUser(data);
setUser(updatedUser); 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({ notifications.show({
message: t("Updated successfully"), message: t("Updated successfully"),
}); });
@@ -57,7 +66,12 @@ export default function AccountNameForm() {
variant="filled" variant="filled"
{...form.getInputProps("name")} {...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")} {t("Save")}
</Button> </Button>
</form> </form>

View File

@@ -41,14 +41,10 @@ export default function ChangePassword() {
); );
} }
const formSchema = z.object({ type FormValues = {
oldPassword: z oldPassword: string;
.string({ error: "your current password is required" }) newPassword: string;
.min(8), };
newPassword: z.string({ error: "New password is required" }).min(8),
});
type FormValues = z.infer<typeof formSchema>;
interface ChangePasswordFormProps { interface ChangePasswordFormProps {
onClose?: () => void; onClose?: () => void;
@@ -57,6 +53,16 @@ function ChangePasswordForm({ onClose }: ChangePasswordFormProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false); 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>({ const form = useForm<FormValues>({
validate: zod4Resolver(formSchema), validate: zod4Resolver(formSchema),
initialValues: { initialValues: {

View File

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

View File

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

View File

@@ -50,7 +50,7 @@ export function WorkspaceInviteForm({ onClose }: Props) {
"Enter valid email addresses separated by comma or space max_50", "Enter valid email addresses separated by comma or space max_50",
)} )}
label={t("Invite by email")} label={t("Invite by email")}
placeholder={t("enter valid emails addresses")} placeholder={t("enter valid email addresses")}
variant="filled" variant="filled"
splitChars={[",", " "]} splitChars={[",", " "]}
maxDropdownHeight={200} maxDropdownHeight={200}

View File

@@ -203,8 +203,48 @@ export default function AiProviderSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isAdmin } = useUserRole(); 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. // 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 updateMutation = useUpdateAiSettingsMutation();
const reindexMutation = useReindexAiEmbeddingsMutation(); const reindexMutation = useReindexAiEmbeddingsMutation();
@@ -877,7 +917,14 @@ export default function AiProviderSettings() {
variant="subtle" variant="subtle"
size="compact-sm" size="compact-sm"
loading={reindexMutation.isPending} 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")} {t("Reindex now")}
</Button> </Button>

View File

@@ -21,11 +21,20 @@ const aiSettingsKey = ["ai-settings"];
export function useAiSettingsQuery( export function useAiSettingsQuery(
enabled: boolean = true, 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> { ): UseQueryResult<IAiSettings, Error> {
return useQuery({ return useQuery({
queryKey: aiSettingsKey, queryKey: aiSettingsKey,
queryFn: () => getAiSettings(), queryFn: () => getAiSettings(),
enabled, enabled,
refetchInterval: refetchInterval as any,
}); });
} }

View File

@@ -84,6 +84,7 @@ export function useDeleteWorkspaceMemberMutation() {
} }
export function useDeactivateWorkspaceMemberMutation() { export function useDeactivateWorkspaceMemberMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation< return useMutation<
@@ -98,6 +99,8 @@ export function useDeactivateWorkspaceMemberMutation() {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["workspaceMembers"], queryKey: ["workspaceMembers"],
}); });
// Notify on success so the action gives visible feedback (issue #128)
notifications.show({ message: t("Member deactivated") });
}, },
onError: (error) => { onError: (error) => {
const errorMessage = error["response"]?.data?.message; const errorMessage = error["response"]?.data?.message;
@@ -107,6 +110,7 @@ export function useDeactivateWorkspaceMemberMutation() {
} }
export function useActivateWorkspaceMemberMutation() { export function useActivateWorkspaceMemberMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation< return useMutation<
@@ -121,6 +125,8 @@ export function useActivateWorkspaceMemberMutation() {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["workspaceMembers"], queryKey: ["workspaceMembers"],
}); });
// Notify on success so the action gives visible feedback (issue #128)
notifications.show({ message: t("Member activated") });
}, },
onError: (error) => { onError: (error) => {
const errorMessage = error["response"]?.data?.message; 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) => { export const computeSpaceSlug = (name: string) => {
const alphanumericName = name.replace(/[^a-zA-Z0-9\s]/g, ""); // Slug is validated as alphanumeric-only (@IsAlphanumeric / ^[a-zA-Z0-9]+$),
if (alphanumericName.includes(" ")) { // so lowercase the name and strip every non-alphanumeric character (spaces,
return alphanumericName // punctuation, unicode). No hyphens or uppercase initials.
.split(" ") return name.toLowerCase().replace(/[^a-z0-9]/g, "");
.map((word) => word.charAt(0).toUpperCase())
.join("");
} else {
return alphanumericName.toLowerCase();
}
}; };
export const formatBytes = (bytes: number): string => { export const formatBytes = (bytes: number): string => {

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,8 @@ import { SkipThrottle, ThrottlerGuard } from '@nestjs/throttler';
import { import {
AI_CHAT_THROTTLER, AI_CHAT_THROTTLER,
AUTH_THROTTLER, AUTH_THROTTLER,
PAGE_TEMPLATE_THROTTLER,
PUBLIC_SHARE_AI_THROTTLER,
} from '../../integrations/throttle/throttler-names'; } from '../../integrations/throttle/throttler-names';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { AuthService } from './services/auth.service'; import { AuthService } from './services/auth.service';
@@ -181,7 +183,18 @@ export class AuthController {
return this.authService.verifyUserToken(verifyUserTokenDto, workspace.id); 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) @UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('collab-token') @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, MinLength,
} from 'class-validator'; } from 'class-validator';
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { NoUrls } from '../../../common/validators/no-urls.validator';
export class CreateWorkspaceDto { export class CreateWorkspaceDto {
@MinLength(1) @MinLength(1)
@MaxLength(64) @MaxLength(64)
@IsString() @IsString()
@NoUrls()
@Transform(({ value }: TransformFnParams) => value?.trim()) @Transform(({ value }: TransformFnParams) => value?.trim())
name: string; name: string;

View File

@@ -86,7 +86,9 @@ export class AiSettingsController {
) { ) {
this.assertAdmin(user, workspace); this.assertAdmin(user, workspace);
await this.aiSettingsService.reindex(workspace.id); 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); return this.aiSettingsService.getMasked(workspace.id);
} }
} }

View File

@@ -8,6 +8,8 @@ import { ServiceUnavailableException } from '@nestjs/common';
*/ */
export class AiSttNotConfiguredException extends ServiceUnavailableException { export class AiSttNotConfiguredException extends ServiceUnavailableException {
constructor() { 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');
} }
} }