Merge branch 'develop' of https://gitea.vvzvlad.xyz/vvzvlad/gitmost into develop

This commit is contained in:
claude_code
2026-06-22 21:14:05 +03:00
45 changed files with 661 additions and 127 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",
@@ -1135,7 +1149,6 @@
"Thinking…": "Thinking…", "Thinking…": "Thinking…",
"The assistant is unavailable right now. Please try again.": "The assistant is unavailable right now. Please try again.", "The assistant is unavailable right now. Please try again.": "The assistant is unavailable right now. Please try again.",
"Public share assistant": "Public share assistant", "Public share assistant": "Public share assistant",
"Enabled": "Enabled",
"Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.": "Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.", "Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.": "Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.",
"Public assistant model": "Public assistant model", "Public assistant model": "Public assistant model",
"Defaults to the chat model": "Defaults to the chat model", "Defaults to the chat model": "Defaults to the chat model",
@@ -1267,5 +1280,7 @@
"Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.": "Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.", "Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.": "Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.",
"Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.": "Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.", "Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.": "Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.",
"Analytics / tracker": "Analytics / tracker", "Analytics / tracker": "Analytics / tracker",
"Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only." "Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.",
"Go to login page": "Go to login page",
"Move to space": "Move to space"
} }

View File

@@ -42,6 +42,23 @@ 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). Accept any
// image/* MIME (png, jpeg, webp, gif, svg, ...) so we don't narrow below
// what the server accepts; only genuinely non-image files are rejected.
if (!file.type.startsWith("image/")) {
notifications.show({
message: t("Unsupported image type"),
color: "red",
});
// Reset the input
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
return;
}
// Validate file size (max 10MB) // 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 +75,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({
@@ -117,7 +136,7 @@ export default function AvatarUploader({
type="file" type="file"
ref={fileInputRef} ref={fileInputRef}
onChange={handleFileInputChange} onChange={handleFileInputChange}
accept="image/png,image/jpeg,image/jpg" accept="image/*"
aria-label={ariaLabel} aria-label={ariaLabel}
tabIndex={-1} tabIndex={-1}
style={{ display: "none" }} style={{ display: "none" }}

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

@@ -26,6 +26,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";
@@ -123,15 +124,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.
@@ -416,6 +415,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;
@@ -427,7 +430,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,47 @@ 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;
// "Done" matches the refetchInterval stop condition (indexed >= total),
// including an empty workspace (0 >= 0), so the deadline clears promptly
// instead of waiting out the cap.
if (settings && settings.indexedPages >= settings.totalPages) {
setReindexDeadline(null);
return;
}
const msLeft = reindexDeadline - Date.now();
if (msLeft <= 0) {
setReindexDeadline(null);
return;
}
const timer = setTimeout(() => setReindexDeadline(null), msLeft);
return () => clearTimeout(timer);
}, [reindexDeadline, settings]);
const updateMutation = useUpdateAiSettingsMutation(); const updateMutation = useUpdateAiSettingsMutation();
const reindexMutation = useReindexAiEmbeddingsMutation(); const reindexMutation = useReindexAiEmbeddingsMutation();
@@ -877,7 +916,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');
} }
} }