f1cffc2d0f
Проба доступности алиаса возвращала currentPageId — id страницы, на которую
алиас уже указывает — ЛЮБОМУ аутентифицированному участнику воркспейса без
проверки прав на просмотр этой страницы. Перебором имён алиасов можно было
смапить их на id страниц, к которым доступа нет.
Теперь checkAvailability отдаёт только {alias, valid, available}. Бита
taken/free достаточно для пробы; заголовок целевой страницы всплывает лишь
ПОСЛЕ реальной попытки setAlias (путь 409 ALIAS_REASSIGN_REQUIRED), который
проверяет права. Клиент currentPageId нигде не использовал — убран из типа,
стейта и теста. Серверный спек утверждает отсутствие currentPageId в ответе.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
273 lines
8.3 KiB
TypeScript
273 lines
8.3 KiB
TypeScript
import {
|
|
ActionIcon,
|
|
Box,
|
|
Button,
|
|
Group,
|
|
Modal,
|
|
Text,
|
|
TextInput,
|
|
} from "@mantine/core";
|
|
import { IconExternalLink } from "@tabler/icons-react";
|
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import CopyTextButton from "@/components/common/copy.tsx";
|
|
import { getAppUrl } from "@/lib/config.ts";
|
|
import {
|
|
useRemoveShareAliasMutation,
|
|
useSetShareAliasMutation,
|
|
useShareAliasForPageQuery,
|
|
} from "@/features/share/queries/share-query.ts";
|
|
import { checkShareAliasAvailability } from "@/features/share/services/share-service.ts";
|
|
import {
|
|
isValidShareAlias,
|
|
normalizeShareAlias,
|
|
} from "@/features/share/share-alias.util.ts";
|
|
|
|
interface ShareAliasSectionProps {
|
|
pageId: string;
|
|
readOnly: boolean;
|
|
}
|
|
|
|
// The prefix label shown next to the slug input, e.g. "docs.example.com/l/".
|
|
function aliasPrefixLabel(): string {
|
|
const url = getAppUrl();
|
|
const host = url.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
return `${host}/l/`;
|
|
}
|
|
|
|
export default function ShareAliasSection({
|
|
pageId,
|
|
readOnly,
|
|
}: ShareAliasSectionProps) {
|
|
const { t } = useTranslation();
|
|
const { data: currentAlias } = useShareAliasForPageQuery(pageId);
|
|
const setAliasMutation = useSetShareAliasMutation();
|
|
const removeAliasMutation = useRemoveShareAliasMutation();
|
|
|
|
const [value, setValue] = useState("");
|
|
const [availability, setAvailability] = useState<{
|
|
valid: boolean;
|
|
available: boolean;
|
|
} | null>(null);
|
|
const [reassign, setReassign] = useState<{
|
|
alias: string;
|
|
currentPageTitle: string | null;
|
|
} | null>(null);
|
|
|
|
// Seed the input from the page's current alias (if any).
|
|
useEffect(() => {
|
|
setValue(currentAlias?.alias ?? "");
|
|
}, [currentAlias?.alias, pageId]);
|
|
|
|
const normalized = useMemo(() => normalizeShareAlias(value), [value]);
|
|
const isValid = isValidShareAlias(normalized);
|
|
const unchanged = currentAlias?.alias === normalized;
|
|
|
|
// Debounced availability probe (skips when invalid or unchanged).
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
|
useEffect(() => {
|
|
setAvailability(null);
|
|
if (!isValid || unchanged) return;
|
|
debounceRef.current && clearTimeout(debounceRef.current);
|
|
debounceRef.current = setTimeout(async () => {
|
|
try {
|
|
const res = await checkShareAliasAvailability(normalized);
|
|
setAvailability({
|
|
valid: res.valid,
|
|
available: res.available,
|
|
});
|
|
} catch {
|
|
setAvailability(null);
|
|
}
|
|
}, 400);
|
|
return () => {
|
|
debounceRef.current && clearTimeout(debounceRef.current);
|
|
};
|
|
}, [normalized, isValid, unchanged]);
|
|
|
|
const prettyLink = currentAlias?.alias
|
|
? `${getAppUrl()}/l/${currentAlias.alias}`
|
|
: null;
|
|
|
|
const handleSave = async (confirmReassign = false) => {
|
|
try {
|
|
await setAliasMutation.mutateAsync({
|
|
pageId,
|
|
alias: normalized,
|
|
confirmReassign,
|
|
});
|
|
setReassign(null);
|
|
} catch (error: any) {
|
|
// The address already points at another page: prompt to move it here.
|
|
if (error?.status === 409 || error?.response?.status === 409) {
|
|
const data = error?.response?.data;
|
|
if (data?.code === "ALIAS_REASSIGN_REQUIRED") {
|
|
setReassign({
|
|
alias: normalized,
|
|
currentPageTitle: data?.currentPageTitle ?? null,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleRemove = async () => {
|
|
if (!currentAlias?.id) return;
|
|
await removeAliasMutation.mutateAsync(currentAlias.id);
|
|
setValue("");
|
|
};
|
|
|
|
const showInvalid = normalized.length > 0 && !isValid;
|
|
// The typed name is already in use by ANOTHER page. This is NOT a dead end:
|
|
// hitting Save triggers the server's 409 `ALIAS_REASSIGN_REQUIRED` and opens
|
|
// the "Move custom address?" confirm modal that retargets the address here.
|
|
// So surface it as an informational hint (not a terminal red error) and keep
|
|
// Save enabled, instead of looking like the address is unusable.
|
|
const reassignable =
|
|
isValid && !unchanged && !!availability && !availability.available;
|
|
|
|
// The slug prefix (e.g. "docs.example.com/l/") is static for the session.
|
|
const prefixLabel = aliasPrefixLabel();
|
|
const prefixRef = useRef<HTMLDivElement>(null);
|
|
const [prefixWidth, setPrefixWidth] = useState(0);
|
|
|
|
// Measure the real rendered width of the prefix so the slug input sits flush
|
|
// next to it, instead of after an over-estimated character-counted gap.
|
|
useLayoutEffect(() => {
|
|
if (prefixRef.current) {
|
|
setPrefixWidth(Math.ceil(prefixRef.current.scrollWidth) + 1);
|
|
}
|
|
}, [prefixLabel]);
|
|
|
|
return (
|
|
<>
|
|
<Text size="sm" fw={500} mt="md">
|
|
{t("Custom address")}
|
|
</Text>
|
|
<Text size="xs" c="dimmed" mb={6}>
|
|
{t("A short, memorable link you can point at any shared page.")}
|
|
</Text>
|
|
|
|
{prettyLink && (
|
|
<Group my="xs" gap={4} wrap="nowrap">
|
|
<TextInput
|
|
variant="filled"
|
|
value={prettyLink}
|
|
readOnly
|
|
rightSection={<CopyTextButton text={prettyLink} />}
|
|
style={{ width: "100%" }}
|
|
/>
|
|
<ActionIcon
|
|
component="a"
|
|
variant="default"
|
|
target="_blank"
|
|
href={prettyLink}
|
|
size="sm"
|
|
>
|
|
<IconExternalLink size={16} />
|
|
</ActionIcon>
|
|
</Group>
|
|
)}
|
|
|
|
<TextInput
|
|
value={value}
|
|
onChange={(e) => setValue(e.currentTarget.value)}
|
|
// Show the canonical form once the user pauses so what they type maps
|
|
// visibly to what gets stored.
|
|
onBlur={() => setValue(normalized)}
|
|
leftSection={
|
|
<Box
|
|
ref={prefixRef}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
width: "100%",
|
|
height: "100%",
|
|
paddingInline: "var(--mantine-spacing-xs)",
|
|
whiteSpace: "nowrap",
|
|
fontSize: "var(--mantine-font-size-xs)",
|
|
color: "var(--mantine-color-dimmed)",
|
|
backgroundColor: "var(--mantine-color-default-hover)",
|
|
borderTopLeftRadius: "var(--input-radius)",
|
|
borderBottomLeftRadius: "var(--input-radius)",
|
|
}}
|
|
>
|
|
{prefixLabel}
|
|
</Box>
|
|
}
|
|
leftSectionWidth={prefixWidth || undefined}
|
|
placeholder={t("my-page")}
|
|
disabled={readOnly}
|
|
error={
|
|
showInvalid
|
|
? t("Use 2-60 lowercase letters, digits and hyphens")
|
|
: undefined
|
|
}
|
|
description={
|
|
reassignable
|
|
? t("This address is in use. Saving will move it to this page.")
|
|
: undefined
|
|
}
|
|
/>
|
|
|
|
<Group mt="sm" gap="xs">
|
|
<Button
|
|
size="compact-sm"
|
|
onClick={() => handleSave(false)}
|
|
loading={setAliasMutation.isPending}
|
|
disabled={readOnly || !isValid || unchanged}
|
|
>
|
|
{t("Save")}
|
|
</Button>
|
|
{currentAlias?.id && (
|
|
<Button
|
|
size="compact-sm"
|
|
variant="default"
|
|
color="red"
|
|
onClick={handleRemove}
|
|
loading={removeAliasMutation.isPending}
|
|
disabled={readOnly}
|
|
>
|
|
{t("Remove")}
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
|
|
<Modal
|
|
opened={!!reassign}
|
|
onClose={() => setReassign(null)}
|
|
title={t("Move custom address?")}
|
|
centered
|
|
size="sm"
|
|
>
|
|
<Text size="sm">
|
|
{reassign?.currentPageTitle
|
|
? t(
|
|
'The address "{{alias}}" currently points to "{{title}}". Move it to this page?',
|
|
{
|
|
alias: reassign?.alias,
|
|
title: reassign?.currentPageTitle,
|
|
},
|
|
)
|
|
: t(
|
|
'The address "{{alias}}" is already in use. Move it to this page?',
|
|
{ alias: reassign?.alias },
|
|
)}
|
|
</Text>
|
|
<Group justify="flex-end" mt="md">
|
|
<Button variant="default" onClick={() => setReassign(null)}>
|
|
{t("Cancel")}
|
|
</Button>
|
|
<Button
|
|
color="red"
|
|
onClick={() => handleSave(true)}
|
|
loading={setAliasMutation.isPending}
|
|
>
|
|
{t("Move here")}
|
|
</Button>
|
|
</Group>
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|