mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8cd5f3e4b | |||
| 39852470cc | |||
| 9a03e1c947 | |||
| cfaa08403a | |||
| 99bc586f34 | |||
| 75838bb311 | |||
| f1c5b145a4 | |||
| 4c7b36dfca | |||
| e1d0d4717c | |||
| e3f836c22b | |||
| e9602ada24 | |||
| 0ff4bed18f | |||
| 6b49d91f2f | |||
| 77f0572445 | |||
| 5b11a0cc16 | |||
| dfe97bee50 | |||
| 500730b243 | |||
| ec6ed809a4 | |||
| 8b3115be9a | |||
| 7782292500 | |||
| a7da968499 | |||
| a95005776f | |||
| 08385b8a9e | |||
| 9929020b44 | |||
| 48a330347f | |||
| 5b6bebc308 | |||
| c831c71c51 | |||
| 90350e82fe | |||
| b7bbaac2eb | |||
| 5a45b95a48 | |||
| 9deb9268b5 | |||
| 53f4c724bb | |||
| 184e56264c | |||
| ffa7043cf0 | |||
| ff3c157554 | |||
| 13f23d19fc | |||
| b527048b76 | |||
| e1b0cfb6a0 | |||
| 2205b9ee87 | |||
| 1122f030a9 | |||
| 4cc0beb90d | |||
| 16084322ca |
@@ -13,3 +13,16 @@ updates:
|
||||
update-types: ["version-update:semver-major"]
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
babel:
|
||||
patterns:
|
||||
- "@babel/*"
|
||||
sentry:
|
||||
patterns:
|
||||
- "@sentry/*"
|
||||
fortawesome:
|
||||
patterns:
|
||||
- "@fortawesome/*"
|
||||
aws:
|
||||
patterns:
|
||||
- "@aws-sdk/*"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { subDays } from "date-fns";
|
||||
import { m } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons";
|
||||
import { CloseIcon, DocumentIcon, ClockIcon, EyeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
@@ -18,6 +19,7 @@ import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Time from "~/components/Time";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useTextStats } from "~/hooks/useTextStats";
|
||||
import CollectionIcon from "./Icons/CollectionIcon";
|
||||
import Text from "./Text";
|
||||
import Tooltip from "./Tooltip";
|
||||
@@ -70,6 +72,10 @@ function DocumentCard(props: Props) {
|
||||
[pin]
|
||||
);
|
||||
|
||||
// If the document was updated within the last 7 days, show a timestamp instead of reading time
|
||||
const isRecentlyUpdated =
|
||||
new Date(document.updatedAt) > subDays(new Date(), 7);
|
||||
|
||||
return (
|
||||
<Reorderable
|
||||
ref={setNodeRef}
|
||||
@@ -142,8 +148,14 @@ function DocumentCard(props: Props) {
|
||||
: document.titleWithDefault}
|
||||
</Heading>
|
||||
<DocumentMeta size="xsmall">
|
||||
<Clock size={18} />
|
||||
<Time dateTime={document.updatedAt} addSuffix shorten />
|
||||
{isRecentlyUpdated ? (
|
||||
<>
|
||||
<Clock size={18} />
|
||||
<Time dateTime={document.updatedAt} addSuffix shorten />
|
||||
</>
|
||||
) : (
|
||||
<ReadingTime document={document} />
|
||||
)}
|
||||
</DocumentMeta>
|
||||
</div>
|
||||
</Content>
|
||||
@@ -164,6 +176,21 @@ function DocumentCard(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const ReadingTime = ({ document }: { document: Document }) => {
|
||||
const { t } = useTranslation();
|
||||
const markdown = React.useMemo(() => document.toMarkdown(), [document]);
|
||||
const stats = useTextStats(markdown);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EyeIcon size={18} />
|
||||
{t(`{{ minutes }}m read`, {
|
||||
minutes: stats.total.readingTime,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentSquircle = ({
|
||||
icon,
|
||||
color,
|
||||
|
||||
@@ -6,10 +6,7 @@ import SuggestionsMenu, {
|
||||
} from "./SuggestionsMenu";
|
||||
import SuggestionsMenuItem from "./SuggestionsMenuItem";
|
||||
|
||||
type Props = Omit<
|
||||
SuggestionsMenuProps,
|
||||
"renderMenuItem" | "items" | "trigger"
|
||||
> &
|
||||
type Props = Omit<SuggestionsMenuProps, "renderMenuItem" | "items"> &
|
||||
Required<Pick<SuggestionsMenuProps, "embeds">>;
|
||||
|
||||
function BlockMenu(props: Props) {
|
||||
|
||||
@@ -17,7 +17,7 @@ type Emoji = {
|
||||
|
||||
type Props = Omit<
|
||||
SuggestionsMenuProps<Emoji>,
|
||||
"renderMenuItem" | "items" | "embeds" | "trigger"
|
||||
"renderMenuItem" | "items" | "embeds"
|
||||
>;
|
||||
|
||||
const EmojiMenu = (props: Props) => {
|
||||
@@ -48,7 +48,6 @@ const EmojiMenu = (props: Props) => {
|
||||
return (
|
||||
<SuggestionsMenu
|
||||
{...props}
|
||||
trigger=":"
|
||||
filterable={false}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<EmojiMenuItem
|
||||
|
||||
@@ -35,7 +35,7 @@ interface MentionItem extends MenuItem {
|
||||
|
||||
type Props = Omit<
|
||||
SuggestionsMenuProps<MentionItem>,
|
||||
"renderMenuItem" | "items" | "embeds" | "trigger"
|
||||
"renderMenuItem" | "items" | "embeds"
|
||||
>;
|
||||
|
||||
function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
@@ -194,7 +194,6 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
{...rest}
|
||||
isActive={isActive}
|
||||
filterable={false}
|
||||
trigger="@"
|
||||
search={search}
|
||||
onSelect={handleSelect}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
|
||||
@@ -9,7 +9,7 @@ import SuggestionsMenuItem from "./SuggestionsMenuItem";
|
||||
|
||||
type Props = Omit<
|
||||
SuggestionsMenuProps,
|
||||
"renderMenuItem" | "items" | "embeds"
|
||||
"renderMenuItem" | "items" | "embeds" | "trigger"
|
||||
> & {
|
||||
pastedText: string;
|
||||
embeds: EmbedDescriptor[];
|
||||
@@ -31,7 +31,7 @@ const PasteMenu = ({ embeds, ...props }: Props) => {
|
||||
const items = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
name: "link",
|
||||
name: "noop",
|
||||
title: t("Keep as link"),
|
||||
icon: <LinkIcon />,
|
||||
},
|
||||
@@ -48,6 +48,7 @@ const PasteMenu = ({ embeds, ...props }: Props) => {
|
||||
return (
|
||||
<SuggestionsMenu
|
||||
{...props}
|
||||
trigger=""
|
||||
filterable={false}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<SuggestionsMenuItem
|
||||
|
||||
@@ -233,7 +233,9 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
const attrs =
|
||||
typeof item.attrs === "function" ? item.attrs(view.state) : item.attrs;
|
||||
|
||||
if (command) {
|
||||
if (item.name === "noop") {
|
||||
// Do nothing
|
||||
} else if (command) {
|
||||
command(attrs);
|
||||
} else {
|
||||
commands[`create${capitalize(item.name)}`](attrs);
|
||||
@@ -435,7 +437,8 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
if (
|
||||
item.name &&
|
||||
!commands[item.name] &&
|
||||
!commands[`create${capitalize(item.name)}`]
|
||||
!commands[`create${capitalize(item.name)}`] &&
|
||||
item.name !== "noop"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -99,22 +99,28 @@ export default class BlockMenuExtension extends Suggestion {
|
||||
];
|
||||
}
|
||||
|
||||
private handleClose = action((insertNewLine: boolean) => {
|
||||
const { view } = this.editor;
|
||||
|
||||
if (insertNewLine) {
|
||||
const transaction = view.state.tr.split(view.state.selection.to);
|
||||
view.dispatch(transaction);
|
||||
view.focus();
|
||||
}
|
||||
|
||||
this.state.open = false;
|
||||
});
|
||||
|
||||
widget = ({ rtl }: WidgetProps) => {
|
||||
const { props, view } = this.editor;
|
||||
const { props } = this.editor;
|
||||
|
||||
return (
|
||||
<BlockMenu
|
||||
rtl={rtl}
|
||||
trigger={this.options.trigger}
|
||||
isActive={this.state.open}
|
||||
search={this.state.query}
|
||||
onClose={action((insertNewLine) => {
|
||||
if (insertNewLine) {
|
||||
const transaction = view.state.tr.split(view.state.selection.to);
|
||||
view.dispatch(transaction);
|
||||
view.focus();
|
||||
}
|
||||
|
||||
this.state.open = false;
|
||||
})}
|
||||
onClose={this.handleClose}
|
||||
uploadFile={props.uploadFile}
|
||||
onFileUploadStart={props.onFileUploadStart}
|
||||
onFileUploadStop={props.onFileUploadStop}
|
||||
|
||||
@@ -33,6 +33,7 @@ export default class EmojiMenuExtension extends Suggestion {
|
||||
widget = ({ rtl }: WidgetProps) => (
|
||||
<EmojiMenu
|
||||
rtl={rtl}
|
||||
trigger={this.options.trigger}
|
||||
isActive={this.state.open}
|
||||
search={this.state.query}
|
||||
onClose={action(() => {
|
||||
|
||||
@@ -21,6 +21,7 @@ export default class MentionMenuExtension extends Suggestion {
|
||||
widget = ({ rtl }: WidgetProps) => (
|
||||
<MentionMenu
|
||||
rtl={rtl}
|
||||
trigger={this.options.trigger}
|
||||
isActive={this.state.open}
|
||||
search={this.state.query}
|
||||
onClose={action(() => {
|
||||
|
||||
@@ -435,7 +435,7 @@ export default class PasteHandler extends Extension {
|
||||
|
||||
private handleSelect = (item: MenuItem) => {
|
||||
switch (item.name) {
|
||||
case "link": {
|
||||
case "noop": {
|
||||
this.hidePasteMenu();
|
||||
this.removePlaceholder();
|
||||
break;
|
||||
@@ -466,7 +466,6 @@ export default class PasteHandler extends Extension {
|
||||
widget = ({ rtl }: WidgetProps) => (
|
||||
<PasteMenu
|
||||
rtl={rtl}
|
||||
trigger=""
|
||||
embeds={this.editor.props.embeds}
|
||||
pastedText={this.state.pastedText}
|
||||
isActive={this.state.open}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import emojiRegex from "emoji-regex";
|
||||
|
||||
/**
|
||||
* Hook to calculate text statistics
|
||||
* @param text The string to calculate statistics for
|
||||
* @param selectedText A substring of the text to calculate statistics for
|
||||
* @returns An object containing total and selected statistics
|
||||
*/
|
||||
export function useTextStats(text: string, selectedText: string = "") {
|
||||
const numTotalWords = countWords(text);
|
||||
const regex = emojiRegex();
|
||||
const matches = Array.from(text.matchAll(regex));
|
||||
|
||||
return {
|
||||
total: {
|
||||
words: numTotalWords,
|
||||
characters: text.length,
|
||||
emoji: matches.length ?? 0,
|
||||
readingTime: Math.max(1, Math.floor(numTotalWords / 200)),
|
||||
},
|
||||
selected: {
|
||||
words: countWords(selectedText),
|
||||
characters: selectedText.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function countWords(text: string): number {
|
||||
const t = text.trim();
|
||||
|
||||
// Hyphenated words are counted as two words
|
||||
return t ? t.replace(/-/g, " ").split(/\s+/g).length : 0;
|
||||
}
|
||||
@@ -6,11 +6,16 @@ import Field from "./decorators/Field";
|
||||
class ApiKey extends ParanoidModel {
|
||||
static modelName = "ApiKey";
|
||||
|
||||
/** The user chosen name of the API key. */
|
||||
/** The human-readable name of this API key */
|
||||
@Field
|
||||
@observable
|
||||
name: string;
|
||||
|
||||
/** A list of scopes that this API key has access to. If empty, the key has full access. */
|
||||
@Field
|
||||
@observable
|
||||
scope?: string[];
|
||||
|
||||
/** An optional datetime that the API key expires. */
|
||||
@Field
|
||||
@observable
|
||||
|
||||
@@ -22,6 +22,7 @@ type Props = {
|
||||
|
||||
function ApiKeyNew({ onSubmit }: Props) {
|
||||
const [name, setName] = React.useState("");
|
||||
const [scope, setScope] = React.useState("");
|
||||
const [expiryType, setExpiryType] = React.useState<ExpiryType>(
|
||||
ExpiryType.Week
|
||||
);
|
||||
@@ -51,6 +52,10 @@ function ApiKeyNew({ onSubmit }: Props) {
|
||||
setName(event.target.value);
|
||||
}, []);
|
||||
|
||||
const handleScopeChange = React.useCallback((event) => {
|
||||
setScope(event.target.value);
|
||||
}, []);
|
||||
|
||||
const handleExpiryTypeChange = React.useCallback((value: string) => {
|
||||
const expiry = value as ExpiryType;
|
||||
setExpiryType(expiry);
|
||||
@@ -70,6 +75,7 @@ function ApiKeyNew({ onSubmit }: Props) {
|
||||
await apiKeys.create({
|
||||
name,
|
||||
expiresAt: expiresAt?.toISOString(),
|
||||
scope: scope ? scope.split(" ") : undefined,
|
||||
});
|
||||
toast.success(
|
||||
t(
|
||||
@@ -83,20 +89,16 @@ function ApiKeyNew({ onSubmit }: Props) {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[t, name, expiresAt, onSubmit, apiKeys]
|
||||
[t, name, scope, expiresAt, onSubmit, apiKeys]
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text as="p" type="secondary">
|
||||
{t(
|
||||
`Name your key something that will help you to remember it's use in the future, for example "local development" or "continuous integration".`
|
||||
)}
|
||||
</Text>
|
||||
<Flex column>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
placeholder={t("Development")}
|
||||
onChange={handleNameChange}
|
||||
value={name}
|
||||
minLength={ApiKeyValidation.minNameLength}
|
||||
@@ -105,6 +107,20 @@ function ApiKeyNew({ onSubmit }: Props) {
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Scopes")}
|
||||
placeholder="documents.info"
|
||||
onChange={handleScopeChange}
|
||||
value={scope}
|
||||
flex
|
||||
/>
|
||||
<Text type="secondary" size="small" as="p">
|
||||
{t(
|
||||
"Space-separated scopes restrict the access of this API key to specific parts of the API. Leave blank for full access"
|
||||
)}
|
||||
.
|
||||
</Text>
|
||||
<Flex align="center" gap={16}>
|
||||
<StyledExpirySelect
|
||||
ariaLabel={t("Expiration")}
|
||||
|
||||
@@ -217,33 +217,31 @@ function SharedDocumentScene(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const SharedDocument = ({
|
||||
shareId,
|
||||
response,
|
||||
}: {
|
||||
shareId?: string;
|
||||
response: Response;
|
||||
}) => {
|
||||
const { setDocument } = useDocumentContext();
|
||||
const SharedDocument = observer(
|
||||
({ shareId, response }: { shareId?: string; response: Response }) => {
|
||||
const { hasHeadings, setDocument } = useDocumentContext();
|
||||
|
||||
if (!response.document) {
|
||||
return null;
|
||||
if (!response.document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tocPosition = hasHeadings
|
||||
? response.team?.tocPosition ?? TOCPosition.Left
|
||||
: false;
|
||||
setDocument(response.document);
|
||||
|
||||
return (
|
||||
<Document
|
||||
abilities={EMPTY_OBJECT}
|
||||
document={response.document}
|
||||
sharedTree={response.sharedTree}
|
||||
shareId={shareId}
|
||||
tocPosition={tocPosition}
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const tocPosition = response.team?.tocPosition ?? TOCPosition.Left;
|
||||
setDocument(response.document);
|
||||
|
||||
return (
|
||||
<Document
|
||||
abilities={EMPTY_OBJECT}
|
||||
document={response.document}
|
||||
sharedTree={response.sharedTree}
|
||||
shareId={shareId}
|
||||
tocPosition={tocPosition}
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
};
|
||||
);
|
||||
|
||||
const Content = styled(Text)`
|
||||
color: ${s("textSecondary")};
|
||||
|
||||
@@ -54,7 +54,7 @@ function CommentThread({
|
||||
collapseThreshold = 5,
|
||||
collapseNumDisplayed = 3,
|
||||
}: Props) {
|
||||
const [focusedOnMount] = React.useState(focused);
|
||||
const [scrollOnMount] = React.useState(focused && !window.location.hash);
|
||||
const { editor } = useDocumentContext();
|
||||
const { comments } = useStores();
|
||||
const topRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -165,7 +165,7 @@ function CommentThread({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (focused) {
|
||||
if (focusedOnMount) {
|
||||
if (scrollOnMount) {
|
||||
setTimeout(() => {
|
||||
if (!topRef.current) {
|
||||
return;
|
||||
@@ -209,7 +209,7 @@ function CommentThread({
|
||||
isMarkVisible ? 0 : sidebarAppearDuration
|
||||
);
|
||||
}
|
||||
}, [focused, focusedOnMount, thread.id]);
|
||||
}, [focused, scrollOnMount, thread.id]);
|
||||
|
||||
return (
|
||||
<Thread
|
||||
|
||||
@@ -243,7 +243,7 @@ function CommentThreadItem({
|
||||
onOpen={disableScroll}
|
||||
onClose={enableScroll}
|
||||
size={28}
|
||||
rounded
|
||||
$rounded
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
@@ -264,7 +264,7 @@ function CommentThreadItem({
|
||||
onSelect={handleAddReaction}
|
||||
onOpen={disableScroll}
|
||||
onClose={enableScroll}
|
||||
rounded
|
||||
$rounded
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -302,7 +302,7 @@ const ResolveButton = ({
|
||||
comment,
|
||||
onResolve: () => onUpdate({ resolved: true }),
|
||||
})}
|
||||
rounded
|
||||
$rounded
|
||||
>
|
||||
<DoneIcon size={22} outline />
|
||||
</Action>
|
||||
@@ -340,10 +340,10 @@ const Body = styled.form`
|
||||
border-radius: 2px;
|
||||
`;
|
||||
|
||||
const Action = styled.span<{ rounded?: boolean }>`
|
||||
const Action = styled.span<{ $rounded?: boolean }>`
|
||||
color: ${s("textSecondary")};
|
||||
${(props) =>
|
||||
props.rounded &&
|
||||
props.$rounded &&
|
||||
css`
|
||||
border-radius: 50%;
|
||||
`}
|
||||
|
||||
@@ -89,7 +89,7 @@ type Props = WithTranslation &
|
||||
revision?: Revision;
|
||||
readOnly: boolean;
|
||||
shareId?: string;
|
||||
tocPosition?: TOCPosition;
|
||||
tocPosition?: TOCPosition | false;
|
||||
onCreateLink?: (
|
||||
params: Properties<Document>,
|
||||
nested?: boolean
|
||||
@@ -438,13 +438,15 @@ class DocumentScene extends React.Component<Props> {
|
||||
const embedsDisabled =
|
||||
(team && team.documentEmbeds === false) || document.embedsDisabled;
|
||||
|
||||
const showContents =
|
||||
(ui.tocVisible === true && !document.isTemplate) ||
|
||||
(isShare && ui.tocVisible !== false);
|
||||
const tocPos =
|
||||
tocPosition ??
|
||||
((team?.getPreference(TeamPreference.TocPosition) as TOCPosition) ||
|
||||
TOCPosition.Left);
|
||||
const showContents =
|
||||
tocPos &&
|
||||
(isShare
|
||||
? ui.tocVisible !== false
|
||||
: !document.isTemplate && ui.tocVisible === true);
|
||||
const multiplayerEditor =
|
||||
!document.isArchived && !document.isDeleted && !revision && !isShare;
|
||||
|
||||
@@ -622,7 +624,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
|
||||
type MainProps = {
|
||||
fullWidth: boolean;
|
||||
tocPosition: TOCPosition;
|
||||
tocPosition: TOCPosition | false;
|
||||
};
|
||||
|
||||
const Main = styled.div<MainProps>`
|
||||
@@ -650,7 +652,7 @@ const Main = styled.div<MainProps>`
|
||||
|
||||
type ContentsContainerProps = {
|
||||
docFullWidth: boolean;
|
||||
position: TOCPosition;
|
||||
position: TOCPosition | false;
|
||||
};
|
||||
|
||||
const ContentsContainer = styled.div<ContentsContainerProps>`
|
||||
@@ -668,7 +670,7 @@ const ContentsContainer = styled.div<ContentsContainerProps>`
|
||||
type EditorContainerProps = {
|
||||
docFullWidth: boolean;
|
||||
showContents: boolean;
|
||||
tocPosition: TOCPosition;
|
||||
tocPosition: TOCPosition | false;
|
||||
};
|
||||
|
||||
const EditorContainer = styled.div<EditorContainerProps>`
|
||||
|
||||
@@ -96,6 +96,7 @@ function DocumentHeader({
|
||||
const ref = React.useRef<HTMLDivElement | null>(null);
|
||||
const size = useComponentSize(ref);
|
||||
const isMobile = isMobileMedia || size.width < 700;
|
||||
const isShare = !!shareId;
|
||||
|
||||
// We cache this value for as long as the component is mounted so that if you
|
||||
// apply a template there is still the option to replace it until the user
|
||||
@@ -109,8 +110,13 @@ function DocumentHeader({
|
||||
}, [onSave]);
|
||||
|
||||
const handleToggle = React.useCallback(() => {
|
||||
ui.set({ tocVisible: !ui.tocVisible });
|
||||
}, [ui]);
|
||||
// Public shares, by default, show ToC on load.
|
||||
if (isShare && ui.tocVisible === undefined) {
|
||||
ui.set({ tocVisible: false });
|
||||
} else {
|
||||
ui.set({ tocVisible: !ui.tocVisible });
|
||||
}
|
||||
}, [ui, isShare]);
|
||||
|
||||
const context = useActionContext({
|
||||
activeDocumentId: document?.id,
|
||||
@@ -120,7 +126,6 @@ function DocumentHeader({
|
||||
const { isDeleted, isTemplate } = document;
|
||||
const isTemplateEditable = can.update && isTemplate;
|
||||
const canToggleEmbeds = team?.documentEmbeds;
|
||||
const isShare = !!shareId;
|
||||
const showContents =
|
||||
(ui.tocVisible === true && !document.isTemplate) ||
|
||||
(isShare && ui.tocVisible !== false);
|
||||
@@ -212,7 +217,9 @@ function DocumentHeader({
|
||||
hasSidebar={sharedTree && sharedTree.children?.length > 0}
|
||||
left={
|
||||
isMobile ? (
|
||||
<TableOfContentsMenu />
|
||||
hasHeadings ? (
|
||||
<TableOfContentsMenu />
|
||||
) : null
|
||||
) : (
|
||||
<PublicBreadcrumb
|
||||
documentId={document.id}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import emojiRegex from "emoji-regex";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -20,6 +19,7 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useTextSelection from "~/hooks/useTextSelection";
|
||||
import { useTextStats } from "~/hooks/useTextStats";
|
||||
import InsightsMenu from "~/menus/InsightsMenu";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import Sidebar from "./SidebarLayout";
|
||||
@@ -213,32 +213,6 @@ function Insights() {
|
||||
);
|
||||
}
|
||||
|
||||
function useTextStats(text: string, selectedText: string) {
|
||||
const numTotalWords = countWords(text);
|
||||
const regex = emojiRegex();
|
||||
const matches = Array.from(text.matchAll(regex));
|
||||
|
||||
return {
|
||||
total: {
|
||||
words: numTotalWords,
|
||||
characters: text.length,
|
||||
emoji: matches.length ?? 0,
|
||||
readingTime: Math.max(1, Math.floor(numTotalWords / 200)),
|
||||
},
|
||||
selected: {
|
||||
words: countWords(selectedText),
|
||||
characters: selectedText.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function countWords(text: string): number {
|
||||
const t = text.trim();
|
||||
|
||||
// Hyphenated words are counted as two words
|
||||
return t ? t.replace(/-/g, " ").split(/\s+/g).length : 0;
|
||||
}
|
||||
|
||||
const ListSpacing = styled("div")`
|
||||
margin-top: -0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
|
||||
@@ -457,13 +457,32 @@ function KeyboardShortcuts() {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("Triggers"),
|
||||
items: [
|
||||
{
|
||||
shortcut: "@",
|
||||
label: t("Mention user or document"),
|
||||
},
|
||||
{
|
||||
shortcut: ":",
|
||||
label: t("Emoji"),
|
||||
},
|
||||
{
|
||||
shortcut: "/",
|
||||
label: t("Insert block"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = React.useState("");
|
||||
const normalizedSearchTerm = searchTerm.toLocaleLowerCase();
|
||||
const handleChange = React.useCallback((event) => {
|
||||
setSearchTerm(event.target.value);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = React.useCallback((event) => {
|
||||
if (event.currentTarget.value && event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
@@ -471,17 +490,20 @@ function KeyboardShortcuts() {
|
||||
setSearchTerm("");
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<InputSearch
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
value={searchTerm}
|
||||
/>
|
||||
<StickySearch>
|
||||
<InputSearch
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
value={searchTerm}
|
||||
/>
|
||||
</StickySearch>
|
||||
{categories.map((category, x) => {
|
||||
const filtered = searchTerm
|
||||
? category.items.filter((item) =>
|
||||
item.label.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
item.label.toLocaleLowerCase().includes(normalizedSearchTerm)
|
||||
)
|
||||
: category.items;
|
||||
|
||||
@@ -509,6 +531,16 @@ function KeyboardShortcuts() {
|
||||
);
|
||||
}
|
||||
|
||||
const StickySearch = styled.div`
|
||||
position: sticky;
|
||||
top: -16px;
|
||||
z-index: 1;
|
||||
padding: 16px;
|
||||
margin: -16px;
|
||||
background: ${s("background")};
|
||||
border-radius: 8px;
|
||||
`;
|
||||
|
||||
const Header = styled.h2`
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -78,7 +78,7 @@ const Profile = () => {
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
border={false}
|
||||
border={env.EMAIL_ENABLED}
|
||||
label={t("Name")}
|
||||
name="name"
|
||||
description={t(
|
||||
@@ -95,7 +95,7 @@ const Profile = () => {
|
||||
</SettingRow>
|
||||
|
||||
{env.EMAIL_ENABLED && (
|
||||
<SettingRow label={t("Email address")} name="email">
|
||||
<SettingRow border={false} label={t("Email address")} name="email">
|
||||
<Input
|
||||
type="email"
|
||||
value={user.email}
|
||||
|
||||
@@ -10,6 +10,7 @@ import Flex from "~/components/Flex";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import ApiKeyMenu from "~/menus/ApiKeyMenu";
|
||||
@@ -35,7 +36,7 @@ const ApiKeyListItem = ({ apiKey }: Props) => {
|
||||
·{" "}
|
||||
</Text>
|
||||
{apiKey.lastActiveAt && (
|
||||
<Text type={"tertiary"}>
|
||||
<Text type="tertiary">
|
||||
{t("Last used")} <Time dateTime={apiKey.lastActiveAt} addSuffix />{" "}
|
||||
·{" "}
|
||||
</Text>
|
||||
@@ -44,7 +45,20 @@ const ApiKeyListItem = ({ apiKey }: Props) => {
|
||||
{apiKey.expiresAt
|
||||
? dateToExpiry(apiKey.expiresAt, t, userLocale)
|
||||
: t("No expiry")}
|
||||
{apiKey.scope && <> · </>}
|
||||
</Text>
|
||||
{apiKey.scope && (
|
||||
<Tooltip
|
||||
content={apiKey.scope.map((s) => (
|
||||
<>
|
||||
{s}
|
||||
<br />
|
||||
</>
|
||||
))}
|
||||
>
|
||||
<Text type="tertiary">{t("Restricted scope")}</Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -106,6 +106,6 @@ export default class GroupsStore extends Store<Group> {
|
||||
|
||||
function queriedGroups(groups: Group[], query: string) {
|
||||
return groups.filter((group) =>
|
||||
group.name.toLowerCase().match(query.toLowerCase())
|
||||
group.name.toLocaleLowerCase().includes(query.toLocaleLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
+18
-18
@@ -53,25 +53,25 @@
|
||||
"@aws-sdk/s3-presigned-post": "3.693.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.693.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.693.0",
|
||||
"@babel/core": "^7.24.7",
|
||||
"@babel/plugin-proposal-decorators": "^7.24.7",
|
||||
"@babel/plugin-transform-class-properties": "^7.24.7",
|
||||
"@babel/plugin-transform-destructuring": "^7.24.8",
|
||||
"@babel/core": "^7.26.7",
|
||||
"@babel/plugin-proposal-decorators": "^7.25.9",
|
||||
"@babel/plugin-transform-class-properties": "^7.25.9",
|
||||
"@babel/plugin-transform-destructuring": "^7.25.9",
|
||||
"@babel/plugin-transform-regenerator": "^7.25.9",
|
||||
"@babel/preset-env": "^7.25.8",
|
||||
"@babel/preset-env": "^7.26.7",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
"@benrbray/prosemirror-math": "^0.2.2",
|
||||
"@bull-board/api": "^4.2.2",
|
||||
"@bull-board/koa": "^4.12.2",
|
||||
"@css-inline/css-inline-wasm": "^0.14.0",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^6.0.1",
|
||||
"@dnd-kit/sortable": "^7.0.2",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@fast-csv/parse": "^5.0.2",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.5.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.2",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||
"@getoutline/react-roving-tabindex": "^3.2.4",
|
||||
"@hocuspocus/extension-throttle": "1.1.2",
|
||||
@@ -85,11 +85,11 @@
|
||||
"@renderlesskit/react": "^0.11.0",
|
||||
"@sentry/node": "^7.120.3",
|
||||
"@sentry/react": "^7.120.3",
|
||||
"@tanstack/react-table": "^8.20.5",
|
||||
"@tanstack/react-virtual": "^3.10.9",
|
||||
"@tanstack/react-table": "^8.20.6",
|
||||
"@tanstack/react-virtual": "^3.11.3",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@types/form-data": "^2.5.0",
|
||||
"@types/mailparser": "^3.4.4",
|
||||
"@types/mailparser": "^3.4.5",
|
||||
"@types/sanitize-filename": "^1.6.3",
|
||||
"@vitejs/plugin-react": "^3.1.0",
|
||||
"addressparser": "^1.0.1",
|
||||
@@ -147,7 +147,7 @@
|
||||
"koa-sslify": "5.0.1",
|
||||
"koa-useragent": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mailparser": "^3.7.1",
|
||||
"mailparser": "^3.7.2",
|
||||
"mammoth": "^1.8.0",
|
||||
"markdown-it": "^13.0.2",
|
||||
"markdown-it-container": "^3.0.0",
|
||||
@@ -199,7 +199,7 @@
|
||||
"react-dom": "^17.0.2",
|
||||
"react-dropzone": "^11.7.1",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-hook-form": "^7.53.1",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-i18next": "^12.3.1",
|
||||
"react-medium-image-zoom": "5.2.10",
|
||||
"react-merge-refs": "^2.1.1",
|
||||
@@ -241,7 +241,7 @@
|
||||
"utility-types": "^3.11.0",
|
||||
"uuid": "^8.3.2",
|
||||
"validator": "13.12.0",
|
||||
"vite": "^5.4.11",
|
||||
"vite": "^5.4.12",
|
||||
"vite-plugin-pwa": "^0.20.3",
|
||||
"winston": "^3.13.0",
|
||||
"ws": "^7.5.10",
|
||||
@@ -254,7 +254,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.26.4",
|
||||
"@babel/preset-typescript": "^7.24.1",
|
||||
"@babel/preset-typescript": "^7.26.0",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@relative-ci/agent": "^4.2.13",
|
||||
"@testing-library/react": "^12.0.0",
|
||||
@@ -289,7 +289,7 @@
|
||||
"@types/markdown-it-emoji": "^2.0.4",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/natural-sort": "^0.0.24",
|
||||
"@types/node": "20.14.2",
|
||||
"@types/node": "20.17.16",
|
||||
"@types/node-fetch": "^2.6.9",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/passport-oauth2": "^1.4.17",
|
||||
@@ -353,7 +353,7 @@
|
||||
"prettier": "^2.8.8",
|
||||
"react-refresh": "^0.14.2",
|
||||
"rimraf": "^2.5.4",
|
||||
"rollup-plugin-webpack-stats": "^0.4.1",
|
||||
"rollup-plugin-webpack-stats": "^2.0.1",
|
||||
"terser": "^5.36.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite-plugin-static-copy": "^0.17.0",
|
||||
|
||||
@@ -35,8 +35,8 @@ type Props = Optional<
|
||||
};
|
||||
|
||||
export default async function documentCreator({
|
||||
title = "",
|
||||
text = "",
|
||||
title,
|
||||
text,
|
||||
icon,
|
||||
color,
|
||||
state,
|
||||
@@ -101,14 +101,20 @@ export default async function documentCreator({
|
||||
fullWidth: templateDocument ? templateDocument.fullWidth : fullWidth,
|
||||
icon: templateDocument ? templateDocument.icon : icon,
|
||||
color: templateDocument ? templateDocument.color : color,
|
||||
title: TextHelper.replaceTemplateVariables(
|
||||
templateDocument ? templateDocument.title : title,
|
||||
user
|
||||
),
|
||||
text: TextHelper.replaceTemplateVariables(
|
||||
templateDocument ? templateDocument.text : text,
|
||||
user
|
||||
),
|
||||
title:
|
||||
title ??
|
||||
(templateDocument
|
||||
? template
|
||||
? templateDocument.title
|
||||
: TextHelper.replaceTemplateVariables(templateDocument.title, user)
|
||||
: ""),
|
||||
text:
|
||||
text ??
|
||||
(templateDocument
|
||||
? template
|
||||
? templateDocument.text
|
||||
: TextHelper.replaceTemplateVariables(templateDocument.text, user)
|
||||
: ""),
|
||||
content: templateDocument
|
||||
? ProsemirrorHelper.replaceTemplateVariables(
|
||||
await DocumentHelper.toJSON(templateDocument),
|
||||
|
||||
@@ -191,9 +191,12 @@ export default abstract class BaseEmail<
|
||||
|
||||
const parsedFrom = addressparser(env.SMTP_FROM_EMAIL)[0];
|
||||
const domain = parsedFrom.address.split("@")[1];
|
||||
const customFromName = this.fromName?.(props);
|
||||
|
||||
return {
|
||||
name: this.fromName?.(props) ?? parsedFrom.name,
|
||||
name: customFromName
|
||||
? `${customFromName} via ${env.APP_NAME}`
|
||||
: parsedFrom.name,
|
||||
address:
|
||||
env.isCloudHosted &&
|
||||
this.category === EmailMessageCategory.Authentication
|
||||
|
||||
@@ -103,7 +103,7 @@ export default class CommentCreatedEmail extends BaseEmail<
|
||||
: `${commentText.slice(0, MAX_SUBJECT_CONTENT)}...`;
|
||||
|
||||
return `${parentComment ? "Re: " : ""}New comment on “${
|
||||
document.title
|
||||
document.titleWithDefault
|
||||
}” - ${trimmedText}`;
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ export default class CommentCreatedEmail extends BaseEmail<
|
||||
}: Props): string {
|
||||
return `
|
||||
${actorName} ${isReply ? "replied to a thread in" : "commented on"} "${
|
||||
document.title
|
||||
document.titleWithDefault
|
||||
}"${collection?.name ? `in the ${collection.name} collection` : ""}.
|
||||
|
||||
Open Thread: ${teamUrl}${document.url}?commentId=${commentId}
|
||||
@@ -164,10 +164,10 @@ Open Thread: ${teamUrl}${document.url}?commentId=${commentId}
|
||||
<Header />
|
||||
|
||||
<Body>
|
||||
<Heading>{document.title}</Heading>
|
||||
<Heading>{document.titleWithDefault}</Heading>
|
||||
<p>
|
||||
{actorName} {isReply ? "replied to a thread in" : "commented on"}{" "}
|
||||
<a href={threadLink}>{document.title}</a>{" "}
|
||||
<a href={threadLink}>{document.titleWithDefault}</a>{" "}
|
||||
{collection?.name ? `in the ${collection.name} collection` : ""}.
|
||||
</p>
|
||||
{body && (
|
||||
|
||||
@@ -92,7 +92,7 @@ export default class CommentMentionedEmail extends BaseEmail<
|
||||
}
|
||||
|
||||
protected subject({ document }: Props) {
|
||||
return `Mentioned you in “${document.title}”`;
|
||||
return `Mentioned you in “${document.titleWithDefault}”`;
|
||||
}
|
||||
|
||||
protected preview({ actorName }: Props): string {
|
||||
@@ -111,7 +111,7 @@ export default class CommentMentionedEmail extends BaseEmail<
|
||||
collection,
|
||||
}: Props): string {
|
||||
return `
|
||||
${actorName} mentioned you in a comment on "${document.title}"${
|
||||
${actorName} mentioned you in a comment on "${document.titleWithDefault}"${
|
||||
collection.name ? `in the ${collection.name} collection` : ""
|
||||
}.
|
||||
|
||||
@@ -139,10 +139,10 @@ Open Thread: ${teamUrl}${document.url}?commentId=${commentId}
|
||||
<Header />
|
||||
|
||||
<Body>
|
||||
<Heading>{document.title}</Heading>
|
||||
<Heading>{document.titleWithDefault}</Heading>
|
||||
<p>
|
||||
{actorName} mentioned you in a comment on{" "}
|
||||
<a href={threadLink}>{document.title}</a>{" "}
|
||||
<a href={threadLink}>{document.titleWithDefault}</a>{" "}
|
||||
{collection.name ? `in the ${collection.name} collection` : ""}.
|
||||
</p>
|
||||
{body && (
|
||||
|
||||
@@ -92,7 +92,7 @@ export default class CommentResolvedEmail extends BaseEmail<
|
||||
}
|
||||
|
||||
protected subject({ document }: Props) {
|
||||
return `Resolved a comment thread in “${document.title}”`;
|
||||
return `Resolved a comment thread in “${document.titleWithDefault}”`;
|
||||
}
|
||||
|
||||
protected preview({ actorName }: Props): string {
|
||||
@@ -110,7 +110,7 @@ export default class CommentResolvedEmail extends BaseEmail<
|
||||
commentId,
|
||||
collection,
|
||||
}: Props): string {
|
||||
const t1 = `${actorName} resolved a comment thread on "${document.title}"`;
|
||||
const t1 = `${actorName} resolved a comment thread on "${document.titleWithDefault}"`;
|
||||
const t2 = collection.name ? ` in the ${collection.name} collection` : "";
|
||||
const t3 = `Open Thread: ${teamUrl}${document.url}?commentId=${commentId}`;
|
||||
return `${t1}${t2}.\n\n${t3}`;
|
||||
@@ -136,10 +136,10 @@ export default class CommentResolvedEmail extends BaseEmail<
|
||||
<Header />
|
||||
|
||||
<Body>
|
||||
<Heading>{document.title}</Heading>
|
||||
<Heading>{document.titleWithDefault}</Heading>
|
||||
<p>
|
||||
{actorName} resolved a comment on{" "}
|
||||
<a href={threadLink}>{document.title}</a>{" "}
|
||||
<a href={threadLink}>{document.titleWithDefault}</a>{" "}
|
||||
{collection.name ? `in the ${collection.name} collection` : ""}.
|
||||
</p>
|
||||
{body && (
|
||||
|
||||
@@ -92,7 +92,7 @@ export default class DocumentMentionedEmail extends BaseEmail<
|
||||
}
|
||||
|
||||
protected subject({ document }: Props) {
|
||||
return `Mentioned you in “${document.title}”`;
|
||||
return `Mentioned you in “${document.titleWithDefault}”`;
|
||||
}
|
||||
|
||||
protected preview({ actorName }: Props): string {
|
||||
@@ -116,7 +116,7 @@ export default class DocumentMentionedEmail extends BaseEmail<
|
||||
return `
|
||||
You were mentioned
|
||||
|
||||
${actorName} mentioned you in the document “${document.title}”.
|
||||
${actorName} mentioned you in the document “${document.titleWithDefault}”.
|
||||
|
||||
Open Document: ${teamUrl}${document.url}
|
||||
`;
|
||||
@@ -137,7 +137,7 @@ Open Document: ${teamUrl}${document.url}
|
||||
<Heading>You were mentioned</Heading>
|
||||
<p>
|
||||
{actorName} mentioned you in the document{" "}
|
||||
<a href={documentLink}>{document.title}</a>.
|
||||
<a href={documentLink}>{document.titleWithDefault}</a>.
|
||||
</p>
|
||||
{body && (
|
||||
<>
|
||||
|
||||
@@ -114,7 +114,7 @@ export default class DocumentPublishedOrUpdatedEmail extends BaseEmail<
|
||||
}
|
||||
|
||||
protected subject({ document, eventType }: Props) {
|
||||
return `“${document.title}” ${this.eventName(eventType)}`;
|
||||
return `“${document.titleWithDefault}” ${this.eventName(eventType)}`;
|
||||
}
|
||||
|
||||
protected preview({ actorName, eventType }: Props): string {
|
||||
@@ -144,9 +144,9 @@ export default class DocumentPublishedOrUpdatedEmail extends BaseEmail<
|
||||
const eventName = this.eventName(eventType);
|
||||
|
||||
return `
|
||||
"${document.title}" ${eventName}
|
||||
"${document.titleWithDefault}" ${eventName}
|
||||
|
||||
${actorName} ${eventName} the document "${document.title}"${
|
||||
${actorName} ${eventName} the document "${document.titleWithDefault}"${
|
||||
collection?.name ? `, in the ${collection.name} collection` : ""
|
||||
}.
|
||||
|
||||
@@ -176,11 +176,11 @@ Open Document: ${teamUrl}${document.url}
|
||||
|
||||
<Body>
|
||||
<Heading>
|
||||
“{document.title}” {eventName}
|
||||
“{document.titleWithDefault}” {eventName}
|
||||
</Heading>
|
||||
<p>
|
||||
{actorName} {eventName} the document{" "}
|
||||
<a href={documentLink}>{document.title}</a>
|
||||
<a href={documentLink}>{document.titleWithDefault}</a>
|
||||
{collection?.name ? <>, in the {collection.name} collection</> : ""}
|
||||
.
|
||||
</p>
|
||||
|
||||
@@ -53,7 +53,7 @@ export default class DocumentSharedEmail extends BaseEmail<
|
||||
}
|
||||
|
||||
protected subject({ actorName, document }: Props) {
|
||||
return `${actorName} shared “${document.title}” with you`;
|
||||
return `${actorName} shared “${document.titleWithDefault}” with you`;
|
||||
}
|
||||
|
||||
protected preview({ actorName }: Props): string {
|
||||
@@ -66,7 +66,7 @@ export default class DocumentSharedEmail extends BaseEmail<
|
||||
|
||||
protected renderAsText({ actorName, teamUrl, document }: Props): string {
|
||||
return `
|
||||
${actorName} shared “${document.title}” with you.
|
||||
${actorName} shared “${document.titleWithDefault}” with you.
|
||||
|
||||
View Document: ${teamUrl}${document.path}
|
||||
`;
|
||||
@@ -87,10 +87,10 @@ View Document: ${teamUrl}${document.path}
|
||||
<Header />
|
||||
|
||||
<Body>
|
||||
<Heading>{document.title}</Heading>
|
||||
<Heading>{document.titleWithDefault}</Heading>
|
||||
<p>
|
||||
{actorName} invited you to {permission} the{" "}
|
||||
<a href={documentUrl}>{document.title}</a> document.
|
||||
<a href={documentUrl}>{document.titleWithDefault}</a> document.
|
||||
</p>
|
||||
<p>
|
||||
<Button href={documentUrl}>View Document</Button>
|
||||
|
||||
@@ -80,7 +80,13 @@ export default function auth(options: AuthenticationOptions = {}) {
|
||||
}
|
||||
|
||||
if (apiKey.expiresAt && apiKey.expiresAt < new Date()) {
|
||||
throw AuthenticationError("Invalid API key");
|
||||
throw AuthenticationError("API key is expired");
|
||||
}
|
||||
|
||||
if (!apiKey.canAccess(ctx.request.url)) {
|
||||
throw AuthenticationError(
|
||||
"API key does not have access to this resource"
|
||||
);
|
||||
}
|
||||
|
||||
user = await User.findByPk(apiKey.userId, {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.sequelize.transaction(async transaction => {
|
||||
await queryInterface.addColumn("apiKeys", "scope", {
|
||||
type: Sequelize.ARRAY(Sequelize.STRING),
|
||||
allowNull: true,
|
||||
}, { transaction });
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await queryInterface.sequelize.transaction(async transaction => {
|
||||
await queryInterface.removeColumn("apiKeys", "scope", { transaction });
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -4,26 +4,26 @@ import ApiKey from "./ApiKey";
|
||||
|
||||
describe("#ApiKey", () => {
|
||||
describe("match", () => {
|
||||
test("should match an API secret", async () => {
|
||||
it("should match an API secret", async () => {
|
||||
const apiKey = await buildApiKey();
|
||||
expect(ApiKey.match(apiKey.value!)).toBe(true);
|
||||
expect(ApiKey.match(`${randomstring.generate(38)}`)).toBe(true);
|
||||
});
|
||||
|
||||
test("should not match non secrets", async () => {
|
||||
it("should not match non secrets", async () => {
|
||||
expect(ApiKey.match("123")).toBe(false);
|
||||
expect(ApiKey.match("1234567890")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lastActiveAt", () => {
|
||||
test("should update lastActiveAt", async () => {
|
||||
it("should update lastActiveAt", async () => {
|
||||
const apiKey = await buildApiKey();
|
||||
await apiKey.updateActiveAt();
|
||||
expect(apiKey.lastActiveAt).toBeTruthy();
|
||||
});
|
||||
|
||||
test("should not update lastActiveAt within 5 minutes", async () => {
|
||||
it("should not update lastActiveAt within 5 minutes", async () => {
|
||||
const apiKey = await buildApiKey();
|
||||
await apiKey.updateActiveAt();
|
||||
expect(apiKey.lastActiveAt).toBeTruthy();
|
||||
@@ -35,7 +35,7 @@ describe("#ApiKey", () => {
|
||||
});
|
||||
|
||||
describe("findByToken", () => {
|
||||
test("should find by hash", async () => {
|
||||
it("should find by hash", async () => {
|
||||
const apiKey = await buildApiKey({
|
||||
name: "Dev",
|
||||
});
|
||||
@@ -44,4 +44,62 @@ describe("#ApiKey", () => {
|
||||
expect(found?.last4).toEqual(apiKey.value!.slice(-4));
|
||||
});
|
||||
});
|
||||
|
||||
describe("canAccess", () => {
|
||||
it("should return true for all resources if no scope", async () => {
|
||||
const apiKey = await buildApiKey({
|
||||
name: "Dev",
|
||||
});
|
||||
|
||||
expect(apiKey.canAccess("/api/documents.info")).toBe(true);
|
||||
expect(apiKey.canAccess("/api/collections.create")).toBe(true);
|
||||
expect(apiKey.canAccess("/api/apiKeys.list")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if no matching scope", async () => {
|
||||
const apiKey = await buildApiKey({
|
||||
name: "Dev",
|
||||
scope: ["/api/documents.info"],
|
||||
});
|
||||
|
||||
expect(apiKey.canAccess("/api/documents.info")).toBe(true);
|
||||
expect(apiKey.canAccess("/api/collections.create")).toBe(false);
|
||||
expect(apiKey.canAccess("/api/apiKeys.list")).toBe(false);
|
||||
});
|
||||
|
||||
it("should allow wildcard methods", async () => {
|
||||
const apiKey = await buildApiKey({
|
||||
name: "Dev",
|
||||
scope: ["/api/documents.*"],
|
||||
});
|
||||
|
||||
expect(apiKey.canAccess("/api/documents.info")).toBe(true);
|
||||
expect(apiKey.canAccess("/api/documents.create")).toBe(true);
|
||||
expect(apiKey.canAccess("/api/collections.create")).toBe(false);
|
||||
});
|
||||
|
||||
it("should allow wildcard namespaces", async () => {
|
||||
const apiKey = await buildApiKey({
|
||||
name: "Dev",
|
||||
scope: ["/api/*.info"],
|
||||
});
|
||||
|
||||
expect(apiKey.canAccess("/api/documents.info")).toBe(true);
|
||||
expect(apiKey.canAccess("/api/documents.create")).toBe(false);
|
||||
expect(apiKey.canAccess("/api/collections.create")).toBe(false);
|
||||
});
|
||||
|
||||
it("should allow multiple scopes", async () => {
|
||||
const apiKey = await buildApiKey({
|
||||
name: "Dev",
|
||||
scope: ["/api/*.info", "/api/collections.list"],
|
||||
});
|
||||
|
||||
expect(apiKey.canAccess("/api/shares.info")).toBe(true);
|
||||
expect(apiKey.canAccess("/api/documents.info")).toBe(true);
|
||||
expect(apiKey.canAccess("/api/collections.list")).toBe(true);
|
||||
expect(apiKey.canAccess("/api/documents.create")).toBe(false);
|
||||
expect(apiKey.canAccess("/api/collections.create")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import crypto from "crypto";
|
||||
import { Matches } from "class-validator";
|
||||
import { subMinutes } from "date-fns";
|
||||
import randomstring from "randomstring";
|
||||
import { InferAttributes, InferCreationAttributes, Op } from "sequelize";
|
||||
@@ -31,6 +32,7 @@ class ApiKey extends ParanoidModel<
|
||||
|
||||
static eventNamespace = "api_keys";
|
||||
|
||||
/** The human-readable name of this API key */
|
||||
@Length({
|
||||
min: ApiKeyValidation.minNameLength,
|
||||
max: ApiKeyValidation.maxNameLength,
|
||||
@@ -39,6 +41,13 @@ class ApiKey extends ParanoidModel<
|
||||
@Column
|
||||
name: string;
|
||||
|
||||
/** A space-separated list of scopes that this API key has access to */
|
||||
@Matches(/[\/\.\w\s]*/, {
|
||||
each: true,
|
||||
})
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
scope: string[] | null;
|
||||
|
||||
/** @deprecated The plain text value of the API key, removed soon. */
|
||||
@Unique
|
||||
@Column
|
||||
@@ -59,10 +68,12 @@ class ApiKey extends ParanoidModel<
|
||||
@SkipChangeset
|
||||
last4: string;
|
||||
|
||||
/** The date and time when this API key will expire */
|
||||
@IsDate
|
||||
@Column
|
||||
expiresAt: Date | null;
|
||||
|
||||
/** The date and time when this API key was last used */
|
||||
@IsDate
|
||||
@Column
|
||||
@SkipChangeset
|
||||
@@ -156,6 +167,27 @@ class ApiKey extends ParanoidModel<
|
||||
|
||||
return this.save({ silent: true });
|
||||
};
|
||||
|
||||
/** Checks if the API key has access to the given path */
|
||||
canAccess = (path: string) => {
|
||||
if (!this.scope) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const resource = path.split("/").pop() ?? "";
|
||||
const [namespace, method] = resource.split(".");
|
||||
|
||||
return this.scope.some((scope) => {
|
||||
const [scopeNamespace, scopeMethod] = scope
|
||||
.replace("/api/", "")
|
||||
.split(".");
|
||||
return (
|
||||
scope.startsWith("/api/") &&
|
||||
(namespace === scopeNamespace || scopeNamespace === "*") &&
|
||||
(method === scopeMethod || scopeMethod === "*")
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default ApiKey;
|
||||
|
||||
@@ -36,8 +36,6 @@ class Attachment extends IdModel<
|
||||
InferAttributes<Attachment>,
|
||||
Partial<InferCreationAttributes<Attachment>>
|
||||
> {
|
||||
static eventNamespace = "attachments";
|
||||
|
||||
@Length({
|
||||
max: 4096,
|
||||
msg: "key must be 4096 characters or less",
|
||||
|
||||
@@ -166,8 +166,6 @@ class Collection extends ParanoidModel<
|
||||
InferAttributes<Collection>,
|
||||
Partial<InferCreationAttributes<Collection>>
|
||||
> {
|
||||
static eventNamespace = "collections";
|
||||
|
||||
@SimpleLength({
|
||||
min: 10,
|
||||
max: 10,
|
||||
|
||||
@@ -42,8 +42,6 @@ class Comment extends ParanoidModel<
|
||||
InferAttributes<Comment>,
|
||||
Partial<InferCreationAttributes<Comment>>
|
||||
> {
|
||||
static eventNamespace = "comments";
|
||||
|
||||
@TextLength({
|
||||
max: CommentValidation.maxLength,
|
||||
msg: `Comment must be less than ${CommentValidation.maxLength} characters`,
|
||||
|
||||
@@ -254,8 +254,6 @@ class Document extends ArchivableModel<
|
||||
InferAttributes<Document>,
|
||||
Partial<InferCreationAttributes<Document>>
|
||||
> {
|
||||
static eventNamespace = "documents";
|
||||
|
||||
@SimpleLength({
|
||||
min: 10,
|
||||
max: 10,
|
||||
|
||||
@@ -60,8 +60,6 @@ class Group extends ParanoidModel<
|
||||
InferAttributes<Group>,
|
||||
Partial<InferCreationAttributes<Group>>
|
||||
> {
|
||||
static eventNamespace = "groups";
|
||||
|
||||
@Length({ min: 0, max: 255, msg: "name must be be 255 characters or less" })
|
||||
@NotContainsUrl
|
||||
@Column
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
AfterDestroy,
|
||||
} from "sequelize-typescript";
|
||||
import { CollectionPermission, DocumentPermission } from "@shared/types";
|
||||
import { APIContext } from "@server/types";
|
||||
import Collection from "./Collection";
|
||||
import Document from "./Document";
|
||||
import Group from "./Group";
|
||||
@@ -157,7 +158,7 @@ class GroupMembership extends ParanoidModel<
|
||||
permission: membership.permission,
|
||||
createdById: membership.createdById,
|
||||
},
|
||||
{ transaction }
|
||||
{ transaction, hooks: false }
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -211,20 +212,12 @@ class GroupMembership extends ParanoidModel<
|
||||
@AfterCreate
|
||||
static async publishAddGroupEventAfterCreate(
|
||||
model: GroupMembership,
|
||||
context: HookContext
|
||||
context: APIContext["context"]
|
||||
) {
|
||||
const data = { membershipId: model.id, isNew: true };
|
||||
|
||||
const ctxWithData = {
|
||||
...context,
|
||||
event: { ...context.event, data },
|
||||
} as HookContext;
|
||||
|
||||
if (model.collectionId) {
|
||||
await Collection.insertEvent("add_group", model, ctxWithData);
|
||||
} else {
|
||||
await Document.insertEvent("add_group", model, ctxWithData);
|
||||
}
|
||||
await model.insertEvent(context, "add_group", {
|
||||
membershipId: model.id,
|
||||
isNew: true,
|
||||
});
|
||||
}
|
||||
|
||||
@AfterUpdate
|
||||
@@ -257,20 +250,12 @@ class GroupMembership extends ParanoidModel<
|
||||
@AfterUpdate
|
||||
static async publishAddGroupEventAfterUpdate(
|
||||
model: GroupMembership,
|
||||
context: HookContext
|
||||
context: APIContext["context"]
|
||||
) {
|
||||
const data = { membershipId: model.id, isNew: false };
|
||||
|
||||
const ctxWithData = {
|
||||
...context,
|
||||
event: { ...context.event, data },
|
||||
} as HookContext;
|
||||
|
||||
if (model.collectionId) {
|
||||
await Collection.insertEvent("add_group", model, ctxWithData);
|
||||
} else {
|
||||
await Document.insertEvent("add_group", model, ctxWithData);
|
||||
}
|
||||
await model.insertEvent(context, "add_group", {
|
||||
membershipId: model.id,
|
||||
isNew: false,
|
||||
});
|
||||
}
|
||||
|
||||
@AfterDestroy
|
||||
@@ -295,20 +280,11 @@ class GroupMembership extends ParanoidModel<
|
||||
@AfterDestroy
|
||||
static async publishRemoveGroupEvent(
|
||||
model: GroupMembership,
|
||||
context: HookContext
|
||||
context: APIContext["context"]
|
||||
) {
|
||||
const data = { membershipId: model.id };
|
||||
|
||||
const ctxWithData = {
|
||||
...context,
|
||||
event: { ...context.event, data },
|
||||
} as HookContext;
|
||||
|
||||
if (model.collectionId) {
|
||||
await Collection.insertEvent("remove_group", model, ctxWithData);
|
||||
} else {
|
||||
await Document.insertEvent("remove_group", model, ctxWithData);
|
||||
}
|
||||
await model.insertEvent(context, "remove_group", {
|
||||
membershipId: model.id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -368,10 +344,28 @@ class GroupMembership extends ParanoidModel<
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
hooks: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async insertEvent(
|
||||
ctx: APIContext["context"],
|
||||
name: string,
|
||||
data: Record<string, unknown>
|
||||
) {
|
||||
const hookContext = {
|
||||
...ctx,
|
||||
event: { name, data, create: true },
|
||||
} as HookContext;
|
||||
|
||||
if (this.collectionId) {
|
||||
await Collection.insertEvent(name, this, hookContext);
|
||||
} else {
|
||||
await Document.insertEvent(name, this, hookContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default GroupMembership;
|
||||
|
||||
@@ -7,12 +7,10 @@ import {
|
||||
Table,
|
||||
DataType,
|
||||
Scopes,
|
||||
AfterCreate,
|
||||
AfterDestroy,
|
||||
} from "sequelize-typescript";
|
||||
import Group from "./Group";
|
||||
import User from "./User";
|
||||
import Model, { type HookContext } from "./base/Model";
|
||||
import Model from "./base/Model";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
@DefaultScope(() => ({
|
||||
@@ -44,6 +42,8 @@ class GroupUser extends Model<
|
||||
InferAttributes<GroupUser>,
|
||||
Partial<InferCreationAttributes<GroupUser>>
|
||||
> {
|
||||
static eventNamespace = "groups";
|
||||
|
||||
@BelongsTo(() => User, "userId")
|
||||
user: User;
|
||||
|
||||
@@ -68,24 +68,6 @@ class GroupUser extends Model<
|
||||
get modelId() {
|
||||
return this.groupId;
|
||||
}
|
||||
|
||||
// hooks
|
||||
|
||||
@AfterCreate
|
||||
public static async publishAddUserEvent(
|
||||
model: GroupUser,
|
||||
context: HookContext
|
||||
) {
|
||||
await Group.insertEvent("add_user", model, context);
|
||||
}
|
||||
|
||||
@AfterDestroy
|
||||
public static async publishRemoveUserEvent(
|
||||
model: GroupUser,
|
||||
context: HookContext
|
||||
) {
|
||||
await Group.insertEvent("remove_user", model, context);
|
||||
}
|
||||
}
|
||||
|
||||
export default GroupUser;
|
||||
|
||||
@@ -20,8 +20,6 @@ class Pin extends IdModel<
|
||||
InferAttributes<Pin>,
|
||||
Partial<InferCreationAttributes<Pin>>
|
||||
> {
|
||||
static eventNamespace = "pins";
|
||||
|
||||
@Length({
|
||||
max: 256,
|
||||
msg: `index must be 256 characters or less`,
|
||||
|
||||
@@ -18,10 +18,10 @@ import {
|
||||
Table,
|
||||
} from "sequelize-typescript";
|
||||
import { createContext } from "@server/context";
|
||||
import { APIContext } from "@server/types";
|
||||
import Comment from "./Comment";
|
||||
import User from "./User";
|
||||
import IdModel from "./base/IdModel";
|
||||
import { type HookContext } from "./base/Model";
|
||||
import Fix from "./decorators/Fix";
|
||||
import Length from "./validators/Length";
|
||||
|
||||
@@ -57,7 +57,7 @@ class Reaction extends IdModel<
|
||||
@AfterCreate
|
||||
public static async addReactionToCommentCache(
|
||||
model: Reaction,
|
||||
ctx: HookContext &
|
||||
ctx: APIContext["context"] &
|
||||
FindOrCreateOptions<Attributes<Reaction>, CreationAttributes<Reaction>>
|
||||
) {
|
||||
const { transaction } = ctx;
|
||||
@@ -109,7 +109,7 @@ class Reaction extends IdModel<
|
||||
@AfterDestroy
|
||||
public static async removeReactionFromCommentCache(
|
||||
model: Reaction,
|
||||
ctx: HookContext & InstanceDestroyOptions
|
||||
ctx: APIContext["context"] & InstanceDestroyOptions
|
||||
) {
|
||||
const { transaction } = ctx;
|
||||
|
||||
|
||||
@@ -83,8 +83,6 @@ class Share extends IdModel<
|
||||
InferAttributes<Share>,
|
||||
Partial<InferCreationAttributes<Share>>
|
||||
> {
|
||||
static eventNamespace = "shares";
|
||||
|
||||
@Column
|
||||
published: boolean;
|
||||
|
||||
|
||||
@@ -19,8 +19,6 @@ class Star extends IdModel<
|
||||
InferAttributes<Star>,
|
||||
Partial<InferCreationAttributes<Star>>
|
||||
> {
|
||||
static eventNamespace = "stars";
|
||||
|
||||
@Length({
|
||||
max: 256,
|
||||
msg: `index must be 256 characters or less`,
|
||||
|
||||
@@ -28,8 +28,6 @@ class Subscription extends ParanoidModel<
|
||||
InferAttributes<Subscription>,
|
||||
Partial<InferCreationAttributes<Subscription>>
|
||||
> {
|
||||
static eventNamespace = "subscriptions";
|
||||
|
||||
@BelongsTo(() => User, "userId")
|
||||
user: User;
|
||||
|
||||
|
||||
@@ -144,12 +144,12 @@ class UserMembership extends IdModel<
|
||||
options: SaveOptions
|
||||
) {
|
||||
const { transaction } = options;
|
||||
const groupMemberships = await this.findAll({
|
||||
const userMemberships = await this.findAll({
|
||||
where,
|
||||
transaction,
|
||||
});
|
||||
await Promise.all(
|
||||
groupMemberships.map((membership) =>
|
||||
userMemberships.map((membership) =>
|
||||
this.create(
|
||||
{
|
||||
documentId: document.id,
|
||||
@@ -158,7 +158,7 @@ class UserMembership extends IdModel<
|
||||
permission: membership.permission,
|
||||
createdById: membership.createdById,
|
||||
},
|
||||
{ transaction }
|
||||
{ transaction, hooks: false }
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
AfterRestore,
|
||||
AfterUpdate,
|
||||
AfterUpsert,
|
||||
BeforeCreate,
|
||||
BeforeSave,
|
||||
Model as SequelizeModel,
|
||||
} from "sequelize-typescript";
|
||||
import Logger from "@server/logging/Logger";
|
||||
@@ -47,8 +47,7 @@ class Model<
|
||||
TCreationAttributes extends {} = TModelAttributes
|
||||
> extends SequelizeModel<TModelAttributes, TCreationAttributes> {
|
||||
/**
|
||||
* The namespace to use for events, if none is provided an event will not be created
|
||||
* during the migration period. In the future this may default to the table name.
|
||||
* The namespace to use for events - defaults to the table name if none is provided.
|
||||
*/
|
||||
static eventNamespace: string | undefined;
|
||||
|
||||
@@ -67,7 +66,6 @@ class Model<
|
||||
create: true,
|
||||
},
|
||||
};
|
||||
this.cacheChangeset();
|
||||
return this.save({ ...options, ...hookContext });
|
||||
}
|
||||
|
||||
@@ -87,7 +85,6 @@ class Model<
|
||||
},
|
||||
};
|
||||
this.set(keys);
|
||||
this.cacheChangeset();
|
||||
return this.save(hookContext);
|
||||
}
|
||||
|
||||
@@ -162,8 +159,8 @@ class Model<
|
||||
return this.create(values, hookContext);
|
||||
}
|
||||
|
||||
@BeforeCreate
|
||||
static async beforeCreateEvent<T extends Model>(model: T) {
|
||||
@BeforeSave
|
||||
static async beforeSaveEvent<T extends Model>(model: T) {
|
||||
model.cacheChangeset();
|
||||
}
|
||||
|
||||
@@ -219,11 +216,10 @@ class Model<
|
||||
model: T,
|
||||
context: HookContext
|
||||
) {
|
||||
const namespace = this.eventNamespace;
|
||||
const namespace = this.eventNamespace ?? this.tableName;
|
||||
const models = this.sequelize!.models;
|
||||
|
||||
// If no namespace is defined, don't create an event
|
||||
if (!namespace || !context.event?.create) {
|
||||
if (!context.event?.create) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import env from "@server/env";
|
||||
import { IncorrectEditionError } from "@server/errors";
|
||||
import { User, Team } from "@server/models";
|
||||
import Model from "@server/models/base/Model";
|
||||
|
||||
@@ -97,9 +96,7 @@ export function isTeamMutable(_actor: User, _model?: Model | null) {
|
||||
*/
|
||||
export function isCloudHosted() {
|
||||
if (!env.isCloudHosted) {
|
||||
throw IncorrectEditionError(
|
||||
"Functionality is not available in this edition"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export default function presentApiKey(apiKey: ApiKey) {
|
||||
id: apiKey.id,
|
||||
userId: apiKey.userId,
|
||||
name: apiKey.name,
|
||||
scope: apiKey.scope,
|
||||
value: apiKey.value,
|
||||
last4: apiKey.last4,
|
||||
createdAt: apiKey.createdAt,
|
||||
|
||||
@@ -40,6 +40,27 @@ describe("#apiKeys.create", () => {
|
||||
expect(body.data.lastActiveAt).toBeNull();
|
||||
});
|
||||
|
||||
it("should allow creating an api key with scopes", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
const res = await server.post("/api/apiKeys.create", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
name: "My API Key",
|
||||
scope: ["/api/documents.list", "*.info", "users.*"],
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.name).toEqual("My API Key");
|
||||
expect(body.data.scope).toEqual([
|
||||
"/api/documents.list",
|
||||
"/api/*.info",
|
||||
"/api/users.*",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/apiKeys.create");
|
||||
expect(res.status).toEqual(401);
|
||||
|
||||
@@ -19,7 +19,7 @@ router.post(
|
||||
validate(T.APIKeysCreateSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.APIKeysCreateReq>) => {
|
||||
const { name, expiresAt } = ctx.input.body;
|
||||
const { name, scope, expiresAt } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
authorize(user, "createApiKey", user.team);
|
||||
@@ -28,6 +28,7 @@ router.post(
|
||||
name,
|
||||
userId: user.id,
|
||||
expiresAt,
|
||||
scope: scope?.map((s) => (s.startsWith("/api/") ? s : `/api/${s}`)),
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
|
||||
@@ -7,6 +7,8 @@ export const APIKeysCreateSchema = BaseSchema.extend({
|
||||
name: z.string(),
|
||||
/** API Key expiry date */
|
||||
expiresAt: z.coerce.date().optional(),
|
||||
/** A list of scopes that this API key has access to */
|
||||
scope: z.array(z.string()).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -211,24 +211,22 @@ router.post(
|
||||
authorize(user, "update", collection);
|
||||
authorize(user, "read", group);
|
||||
|
||||
const [membership, created] = await GroupMembership.findOrCreateWithCtx(
|
||||
ctx,
|
||||
{
|
||||
where: {
|
||||
collectionId: id,
|
||||
groupId,
|
||||
},
|
||||
defaults: {
|
||||
permission,
|
||||
createdById: user.id,
|
||||
},
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
}
|
||||
);
|
||||
const [membership, created] = await GroupMembership.findOrCreate({
|
||||
where: {
|
||||
collectionId: id,
|
||||
groupId,
|
||||
},
|
||||
defaults: {
|
||||
permission,
|
||||
createdById: user.id,
|
||||
},
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
...ctx.context,
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
membership.permission = permission;
|
||||
await membership.saveWithCtx(ctx);
|
||||
await membership.save(ctx.context);
|
||||
}
|
||||
|
||||
const groupMemberships = [presentGroupMembership(membership)];
|
||||
@@ -273,7 +271,7 @@ router.post(
|
||||
ctx.throw(400, "This Group is not a part of the collection");
|
||||
}
|
||||
|
||||
await membership.destroyWithCtx(ctx);
|
||||
await membership.destroy(ctx.context);
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
|
||||
@@ -381,12 +381,13 @@ router.post(
|
||||
authorize(user, "comment", document);
|
||||
authorize(user, "addReaction", comment);
|
||||
|
||||
await Reaction.findOrCreateWithCtx(ctx, {
|
||||
await Reaction.findOrCreate({
|
||||
where: {
|
||||
emoji,
|
||||
userId: user.id,
|
||||
commentId: id,
|
||||
},
|
||||
...ctx.context,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
@@ -429,7 +430,7 @@ router.post(
|
||||
});
|
||||
authorize(user, "delete", reaction);
|
||||
|
||||
await reaction.destroyWithCtx(ctx);
|
||||
await reaction.destroy(ctx.context);
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
StatusFilter,
|
||||
UserRole,
|
||||
} from "@shared/types";
|
||||
import { TextHelper } from "@shared/utils/TextHelper";
|
||||
import { createContext } from "@server/context";
|
||||
import {
|
||||
Document,
|
||||
@@ -3357,6 +3358,127 @@ describe("#documents.import", () => {
|
||||
});
|
||||
|
||||
describe("#documents.create", () => {
|
||||
it("should replace template variables when a doc is created from a template", async () => {
|
||||
const user = await buildUser();
|
||||
const template = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
template: true,
|
||||
title: "template title",
|
||||
text: "Created by user {author} on {date}",
|
||||
});
|
||||
const res = await server.post("/api/documents.create", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
templateId: template.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.title).toEqual(
|
||||
TextHelper.replaceTemplateVariables(template.title, user)
|
||||
);
|
||||
expect(body.data.text).toEqual(
|
||||
TextHelper.replaceTemplateVariables(template.text, user)
|
||||
);
|
||||
});
|
||||
|
||||
it("should retain template variables when a template is created from another template", async () => {
|
||||
const user = await buildUser();
|
||||
const template = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
template: true,
|
||||
title: "template title",
|
||||
text: "Created by user {author} on {date}",
|
||||
});
|
||||
const res = await server.post("/api/documents.create", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
templateId: template.id,
|
||||
template: true,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.title).toEqual(template.title);
|
||||
expect(body.data.text).toEqual(template.text);
|
||||
});
|
||||
|
||||
it("should create a document with empty title if no title is explicitly passed", async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/documents.create", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
text: "hello",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.title).toEqual("");
|
||||
});
|
||||
|
||||
it("should use template title when doc is supposed to be created using the template and title is not explicitly passed", async () => {
|
||||
const user = await buildUser();
|
||||
const template = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
template: true,
|
||||
title: "template title",
|
||||
text: "template text",
|
||||
});
|
||||
const res = await server.post("/api/documents.create", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
templateId: template.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.title).toEqual(template.title);
|
||||
expect(body.data.text).toEqual(template.text);
|
||||
});
|
||||
|
||||
it("should override template title when doc title is explicitly passed", async () => {
|
||||
const user = await buildUser();
|
||||
const template = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
template: true,
|
||||
title: "template title",
|
||||
});
|
||||
const res = await server.post("/api/documents.create", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
templateId: template.id,
|
||||
title: "doc title",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.title).toEqual("doc title");
|
||||
});
|
||||
|
||||
it("should override template text when doc text is explicitly passed", async () => {
|
||||
const user = await buildUser();
|
||||
const template = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
template: true,
|
||||
text: "template text",
|
||||
});
|
||||
const res = await server.post("/api/documents.create", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
templateId: template.id,
|
||||
text: "doc text",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.text).toEqual("doc text");
|
||||
});
|
||||
|
||||
it("should fail for invalid collectionId", async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/documents.create", {
|
||||
|
||||
@@ -6,6 +6,7 @@ import JSZip from "jszip";
|
||||
import Router from "koa-router";
|
||||
import escapeRegExp from "lodash/escapeRegExp";
|
||||
import has from "lodash/has";
|
||||
import isNil from "lodash/isNil";
|
||||
import remove from "lodash/remove";
|
||||
import uniq from "lodash/uniq";
|
||||
import mime from "mime-types";
|
||||
@@ -1644,7 +1645,9 @@ router.post(
|
||||
const document = await documentCreator({
|
||||
id,
|
||||
title,
|
||||
text: await TextHelper.replaceImagesWithAttachments(ctx, text, user),
|
||||
text: !isNil(text)
|
||||
? await TextHelper.replaceImagesWithAttachments(ctx, text, user)
|
||||
: text,
|
||||
icon,
|
||||
color,
|
||||
createdAt,
|
||||
@@ -1841,20 +1844,18 @@ router.post(
|
||||
authorize(user, "update", document);
|
||||
authorize(user, "read", group);
|
||||
|
||||
const [membership, created] = await GroupMembership.findOrCreateWithCtx(
|
||||
ctx,
|
||||
{
|
||||
where: {
|
||||
documentId: id,
|
||||
groupId,
|
||||
},
|
||||
defaults: {
|
||||
permission: permission || user.defaultDocumentPermission,
|
||||
createdById: user.id,
|
||||
},
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
}
|
||||
);
|
||||
const [membership, created] = await GroupMembership.findOrCreate({
|
||||
where: {
|
||||
documentId: id,
|
||||
groupId,
|
||||
},
|
||||
defaults: {
|
||||
permission: permission || user.defaultDocumentPermission,
|
||||
createdById: user.id,
|
||||
},
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
...ctx.context,
|
||||
});
|
||||
|
||||
if (!created && permission) {
|
||||
membership.permission = permission;
|
||||
@@ -1862,7 +1863,7 @@ router.post(
|
||||
// disconnect from the source if the permission is manually updated
|
||||
membership.sourceId = null;
|
||||
|
||||
await membership.saveWithCtx(ctx);
|
||||
await membership.save(ctx.context);
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
@@ -1907,7 +1908,7 @@ router.post(
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
|
||||
await membership.destroyWithCtx(ctx);
|
||||
await membership.destroy(ctx.context);
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
|
||||
@@ -331,10 +331,10 @@ export const DocumentsCreateSchema = BaseSchema.extend({
|
||||
id: z.string().uuid().optional(),
|
||||
|
||||
/** Document title */
|
||||
title: z.string().default(""),
|
||||
title: z.string().optional(),
|
||||
|
||||
/** Document text */
|
||||
text: z.string().default(""),
|
||||
text: z.string().optional(),
|
||||
|
||||
/** Icon displayed alongside doc title */
|
||||
icon: zodIconType().optional(),
|
||||
|
||||
@@ -261,15 +261,19 @@ router.post(
|
||||
const group = await Group.findByPk(id, { transaction });
|
||||
authorize(actor, "update", group);
|
||||
|
||||
const [groupUser] = await GroupUser.findOrCreateWithCtx(ctx, {
|
||||
where: {
|
||||
groupId: group.id,
|
||||
userId: user.id,
|
||||
const [groupUser] = await GroupUser.findOrCreateWithCtx(
|
||||
ctx,
|
||||
{
|
||||
where: {
|
||||
groupId: group.id,
|
||||
userId: user.id,
|
||||
},
|
||||
defaults: {
|
||||
createdById: actor.id,
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
createdById: actor.id,
|
||||
},
|
||||
});
|
||||
{ name: "add_user" }
|
||||
);
|
||||
|
||||
groupUser.user = user;
|
||||
|
||||
@@ -308,7 +312,7 @@ router.post(
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
|
||||
await groupUser?.destroyWithCtx(ctx);
|
||||
await groupUser?.destroyWithCtx(ctx, { name: "remove_user" });
|
||||
|
||||
ctx.body = {
|
||||
data: {
|
||||
|
||||
@@ -67,7 +67,7 @@ export class DocumentConverter {
|
||||
}
|
||||
|
||||
public static async htmlToMarkdown(content: Buffer | string) {
|
||||
if (content instanceof Buffer) {
|
||||
if (typeof content !== "string") {
|
||||
content = content.toString("utf8");
|
||||
}
|
||||
|
||||
@@ -117,26 +117,26 @@ export class DocumentConverter {
|
||||
}
|
||||
|
||||
public static fileToMarkdown(content: Buffer | string) {
|
||||
if (content instanceof Buffer) {
|
||||
if (typeof content !== "string") {
|
||||
content = content.toString("utf8");
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
public static async confluenceToMarkdown(value: Buffer | string) {
|
||||
if (value instanceof Buffer) {
|
||||
value = value.toString("utf8");
|
||||
public static async confluenceToMarkdown(content: Buffer | string) {
|
||||
if (typeof content !== "string") {
|
||||
content = content.toString("utf8");
|
||||
}
|
||||
|
||||
// We're only supporting the output from Confluence here, regular Word documents should call
|
||||
// into the docxToMarkdown importer. See: https://jira.atlassian.com/browse/CONFSERVER-38237
|
||||
if (!value.includes("Content-Type: multipart/related")) {
|
||||
if (!content.includes("Content-Type: multipart/related")) {
|
||||
throw FileImportError("Unsupported Word file");
|
||||
}
|
||||
|
||||
// Confluence "Word" documents are actually just multi-part email messages, so we can use
|
||||
// mailparser to parse the content.
|
||||
const parsed = await simpleParser(value);
|
||||
const parsed = await simpleParser(content);
|
||||
if (!parsed.html) {
|
||||
throw FileImportError("Unsupported Word file (No content found)");
|
||||
}
|
||||
|
||||
@@ -30,7 +30,9 @@ export default class Mention extends Node {
|
||||
get schema(): NodeSpec {
|
||||
return {
|
||||
attrs: {
|
||||
type: {},
|
||||
type: {
|
||||
default: MentionType.User,
|
||||
},
|
||||
label: {},
|
||||
modelId: {},
|
||||
actorId: {
|
||||
|
||||
@@ -197,6 +197,7 @@
|
||||
"Install now": "Install now",
|
||||
"Deleted Collection": "Deleted Collection",
|
||||
"Unpin": "Unpin",
|
||||
"{{ minutes }}m read": "{{ minutes }}m read",
|
||||
"Select a location to copy": "Select a location to copy",
|
||||
"Document copied": "Document copied",
|
||||
"Couldn’t copy the document, try again?": "Couldn’t copy the document, try again?",
|
||||
@@ -569,7 +570,8 @@
|
||||
"invited you to": "invited you to",
|
||||
"Choose a date": "Choose a date",
|
||||
"API key created. Please copy the value now as it will not be shown again.": "API key created. Please copy the value now as it will not be shown again.",
|
||||
"Name your key something that will help you to remember it's use in the future, for example \"local development\" or \"continuous integration\".": "Name your key something that will help you to remember it's use in the future, for example \"local development\" or \"continuous integration\".",
|
||||
"Scopes": "Scopes",
|
||||
"Space-separated scopes restrict the access of this API key to specific parts of the API. Leave blank for full access": "Space-separated scopes restrict the access of this API key to specific parts of the API. Leave blank for full access",
|
||||
"Expiration": "Expiration",
|
||||
"Never expires": "Never expires",
|
||||
"7 days": "7 days",
|
||||
@@ -761,6 +763,10 @@
|
||||
"LaTeX block": "LaTeX block",
|
||||
"Inline code": "Inline code",
|
||||
"Inline LaTeX": "Inline LaTeX",
|
||||
"Triggers": "Triggers",
|
||||
"Mention user or document": "Mention user or document",
|
||||
"Emoji": "Emoji",
|
||||
"Insert block": "Insert block",
|
||||
"Sign In": "Sign In",
|
||||
"Continue with Email": "Continue with Email",
|
||||
"Continue with {{ authProviderName }}": "Continue with {{ authProviderName }}",
|
||||
@@ -823,6 +829,7 @@
|
||||
"by {{ name }}": "by {{ name }}",
|
||||
"Last used": "Last used",
|
||||
"No expiry": "No expiry",
|
||||
"Restricted scope": "Restricted scope",
|
||||
"API key copied to clipboard": "API key copied to clipboard",
|
||||
"Copied": "Copied",
|
||||
"Revoking": "Revoking",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"Star": "Stjerne",
|
||||
"Unstar": "Fjern stjerne",
|
||||
"Archive": "Arkiv",
|
||||
"Archive collection": "Archive collection",
|
||||
"Archive collection": "Arkiver samling",
|
||||
"Collection archived": "Samling arkivert",
|
||||
"Archiving": "Arkivering",
|
||||
"Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results.": "Ved å arkivere denne samlingen vil du også arkivere alle dokumenter inkludert. Dokumenter fra samlingen vil ikke lenger være synlige i søkeresultater.",
|
||||
@@ -94,9 +94,9 @@
|
||||
"Insights": "Innsikt",
|
||||
"Disable viewer insights": "Deaktiver leserinnsikt",
|
||||
"Enable viewer insights": "Aktiver leserinnsikt",
|
||||
"Leave document": "Leave document",
|
||||
"You have left the shared document": "You have left the shared document",
|
||||
"Could not leave document": "Could not leave document",
|
||||
"Leave document": "Forlat dokument",
|
||||
"You have left the shared document": "Du har forlatt det delte dokumentet",
|
||||
"Could not leave document": "Kunne ikke forlate dokument",
|
||||
"Home": "Hjem",
|
||||
"Drafts": "Utkast",
|
||||
"Trash": "Søppel",
|
||||
@@ -199,9 +199,9 @@
|
||||
"Unpin": "Løsne",
|
||||
"Select a location to copy": "Velg en plassering å kopiere",
|
||||
"Document copied": "Dokument kopiert",
|
||||
"Couldn’t copy the document, try again?": "Couldn’t copy the document, try again?",
|
||||
"Couldn’t copy the document, try again?": "Kunne ikke kopiere dokumentet, prøv igjen?",
|
||||
"Include nested documents": "Inkluder underdokumenter",
|
||||
"Copy to <em>{{ location }}</em>": "Copy to <em>{{ location }}</em>",
|
||||
"Copy to <em>{{ location }}</em>": "Kopier til <em>{{ location }}</em>",
|
||||
"Search collections & documents": "Søk i samlinger og dokumenter",
|
||||
"No results found": "Ingen resultater funnet",
|
||||
"Untitled": "Uten tittel",
|
||||
@@ -303,14 +303,14 @@
|
||||
"Unknown": "Ukjent",
|
||||
"Mark all as read": "Merk alle som lest",
|
||||
"You're all caught up": "Du er helt oppdatert",
|
||||
"{{ username }} reacted with {{ emoji }}": "{{ username }} reacted with {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}": "{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}": "{{ firstUsername }} and {{ count }} other reacted with {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}_plural": "{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}",
|
||||
"Add reaction": "Add reaction",
|
||||
"Reaction picker": "Reaction picker",
|
||||
"Could not load reactions": "Could not load reactions",
|
||||
"Reaction": "Reaction",
|
||||
"{{ username }} reacted with {{ emoji }}": "{{ username }} reagerte med {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}": "{{ firstUsername }} og {{ secondUsername }} reagerte med {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}": "{{ firstUsername }} og {{ count }} andre reagerte med {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}_plural": "{{ firstUsername }} og {{ count }} andre reagerte med {{ emoji }}",
|
||||
"Add reaction": "Legg til reaksjon",
|
||||
"Reaction picker": "Reaksjonsvelger",
|
||||
"Could not load reactions": "Kunne ikke laste reaksjoner",
|
||||
"Reaction": "Reaksjon",
|
||||
"Results": "Resultater",
|
||||
"No results for {{query}}": "Ingen resultater for {{query}}",
|
||||
"Manage": "Administrer",
|
||||
@@ -355,8 +355,8 @@
|
||||
"Anyone with the link can access because the parent document, <2>{{documentTitle}}</2>, is shared": "Alle med koblingen har tilgang fordi det overordnede dokumentet, <2>{{documentTitle}}</2>, deles",
|
||||
"Allow anyone with the link to access": "Tillat alle med lenken å få tilgang til",
|
||||
"Publish to internet": "Publiser til internett",
|
||||
"Search engine indexing": "Search engine indexing",
|
||||
"Disable this setting to discourage search engines from indexing the page": "Disable this setting to discourage search engines from indexing the page",
|
||||
"Search engine indexing": "Søkemotorindeksering",
|
||||
"Disable this setting to discourage search engines from indexing the page": "Deaktiver denne innstillingen for å fraråde søkemotorer til å indeksere denne siden",
|
||||
"Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future": "Underdokumenter deles ikke på nettet. Aktiver deling for å tillate tilgang, dette vil være standard oppførsel i fremtiden",
|
||||
"{{ userName }} was added to the document": "{{ userName }} ble lagt til i dokumentet",
|
||||
"{{ count }} people added to the document": "{{ count }} person er lagt til i dokumentet",
|
||||
@@ -364,8 +364,8 @@
|
||||
"{{ count }} groups added to the document": "{{ count }} grupper er lagt til i dokumentet",
|
||||
"{{ count }} groups added to the document_plural": "{{ count }} grupper er lagt til i dokumentene",
|
||||
"Logo": "Logo",
|
||||
"Archived collections": "Archived collections",
|
||||
"Change permissions?": "Change permissions?",
|
||||
"Archived collections": "Arkiverte samlinger",
|
||||
"Change permissions?": "Endre tillatelser?",
|
||||
"New doc": "Nytt dokument",
|
||||
"You can't reorder documents in an alphabetically sorted collection": "Du kan ikke omorganisere dokumenter i en alfabetisk sortert samling",
|
||||
"Empty": "Tom",
|
||||
@@ -399,12 +399,12 @@
|
||||
"Are you sure you want to suspend {{ userName }}? Suspended users will be prevented from logging in.": "Er du sikker på at du vil suspendere {{ userName }}? Suspendering vil hindre brukeren i å logge inn.",
|
||||
"New name": "Nytt navn",
|
||||
"Name can't be empty": "Navn kan ikke være tomt",
|
||||
"Check your email to verify the new address.": "Check your email to verify the new address.",
|
||||
"The email will be changed once verified.": "The email will be changed once verified.",
|
||||
"You will receive an email to verify your new address. It must be unique in the workspace.": "You will receive an email to verify your new address. It must be unique in the workspace.",
|
||||
"A confirmation email will be sent to the new address before it is changed.": "A confirmation email will be sent to the new address before it is changed.",
|
||||
"New email": "New email",
|
||||
"Email can't be empty": "Email can't be empty",
|
||||
"Check your email to verify the new address.": "Sjekk e-posten din for å verifisere den nye adressen.",
|
||||
"The email will be changed once verified.": "E-posten vil bli endret når den er blitt verifisert.",
|
||||
"You will receive an email to verify your new address. It must be unique in the workspace.": "Du vil motta en e-post for å verifisere din nye e-postadresse. Den må være unik i arbeidsområdet.",
|
||||
"A confirmation email will be sent to the new address before it is changed.": "En e-post med bekreftelse vil bli sendt til den nye e-postadressen før den endres.",
|
||||
"New email": "Ny e-post",
|
||||
"Email can't be empty": "E-post kan ikke være tom",
|
||||
"Your import completed": "Importen din er fullført",
|
||||
"Previous match": "Forrige treff",
|
||||
"Next match": "Neste treff",
|
||||
@@ -418,7 +418,7 @@
|
||||
"Replace all": "Erstatt alle",
|
||||
"Profile picture": "Profilbilde",
|
||||
"Create a new doc": "Lag et nytt dokument",
|
||||
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} won't be notified, as they do not have access to this document",
|
||||
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} vil ikke bli varslet, da de ikke har tilgang til dette dokumentet",
|
||||
"Add column after": "Legg til kolonne etter",
|
||||
"Add column before": "Sett inn kolonne før",
|
||||
"Add row after": "Legg til rad etter",
|
||||
@@ -447,11 +447,11 @@
|
||||
"Italic": "Kursiv",
|
||||
"Sorry, that link won’t work for this embed type": "Beklager, den lenken vil ikke fungere for denne innebygde typen",
|
||||
"File attachment": "Filvedlegg",
|
||||
"Enter a link": "Enter a link",
|
||||
"Enter a link": "Skriv inn en lenke",
|
||||
"Big heading": "Stor overskrift",
|
||||
"Medium heading": "Middels overskrift",
|
||||
"Small heading": "Liten overskrift",
|
||||
"Extra small heading": "Extra small heading",
|
||||
"Extra small heading": "Ekstra liten overskrift",
|
||||
"Heading": "Overskrift",
|
||||
"Divider": "Skillelinje",
|
||||
"Image": "Bilde",
|
||||
@@ -480,7 +480,7 @@
|
||||
"Sort ascending": "Sorter stigende",
|
||||
"Sort descending": "Sorter synkende",
|
||||
"Table": "Tabell",
|
||||
"Export as CSV": "Export as CSV",
|
||||
"Export as CSV": "Eksporter som CSV",
|
||||
"Toggle header": "Vis/skjul topp",
|
||||
"Math inline (LaTeX)": "Matematikk i linje (LaTeX)",
|
||||
"Math block (LaTeX)": "Matematikkblokk (LaTeX)",
|
||||
@@ -500,7 +500,7 @@
|
||||
"Could not import file": "Kunne ikke importere fil",
|
||||
"Unsubscribed from document": "Avsluttet abonnement fra dokument",
|
||||
"Account": "Konto",
|
||||
"API Keys": "API Keys",
|
||||
"API Keys": "API-nøkler",
|
||||
"Details": "Detaljer",
|
||||
"Security": "Sikkerhet",
|
||||
"Features": "Funksjoner",
|
||||
@@ -518,8 +518,8 @@
|
||||
"Export collection": "Eksporter samling",
|
||||
"Rename": "Gi nytt navn",
|
||||
"Sort in sidebar": "Sorter i sidemeny",
|
||||
"A-Z sort": "A-Z sort",
|
||||
"Z-A sort": "Z-A sort",
|
||||
"A-Z sort": "A-Å sortering",
|
||||
"Z-A sort": "Å-A sortering",
|
||||
"Manual sort": "Manuell sortering",
|
||||
"Comment options": "Kommentaralternativer",
|
||||
"Show document menu": "Vis dokumentmeny",
|
||||
@@ -547,7 +547,7 @@
|
||||
"Headings you add to the document will appear here": "Overskrifter du legger til i dokumentet vil vises her",
|
||||
"Table of contents": "Innholdsfortegnelse",
|
||||
"Change name": "Endre navn",
|
||||
"Change email": "Change email",
|
||||
"Change email": "Endre e-post",
|
||||
"Suspend user": "Suspendere bruker",
|
||||
"An error occurred while sending the invite": "En feil oppstod under sending av invitasjonen",
|
||||
"User options": "Brukeralternativer",
|
||||
@@ -562,11 +562,11 @@
|
||||
"created the collection": "opprettet samlingen",
|
||||
"mentioned you in": "nevnte deg i",
|
||||
"left a comment on": "la igjen en kommentar på",
|
||||
"resolved a comment on": "resolved a comment on",
|
||||
"resolved a comment on": "løste en kommentar på",
|
||||
"shared": "delt",
|
||||
"invited you to": "inviterte deg til",
|
||||
"Choose a date": "Velg en dato",
|
||||
"API key created. Please copy the value now as it will not be shown again.": "API key created. Please copy the value now as it will not be shown again.",
|
||||
"API key created. Please copy the value now as it will not be shown again.": "API-nøkkel opprettet. Kopier verdien nå, da den ikke vil bli vist igjen.",
|
||||
"Name your key something that will help you to remember it's use in the future, for example \"local development\" or \"continuous integration\".": "Gi nøkkelen et navn som hjelper deg å huske dens bruk i fremtiden, for eksempel \"lokale utvikling\", \"produksjon\", eller \"kontinuerlig integrasjon\".",
|
||||
"Expiration": "Utløper",
|
||||
"Never expires": "Utløper aldri",
|
||||
@@ -605,13 +605,13 @@
|
||||
"Upload image": "Last opp bilde",
|
||||
"No resolved comments": "Ingen løste kommentarer",
|
||||
"No comments yet": "Ingen kommentarer enda",
|
||||
"New comments": "New comments",
|
||||
"Sort comments": "Sort comments",
|
||||
"Most recent": "Most recent",
|
||||
"Order in doc": "Order in doc",
|
||||
"Resolved": "Resolved",
|
||||
"Show {{ count }} reply": "Show {{ count }} reply",
|
||||
"Show {{ count }} reply_plural": "Show {{ count }} replies",
|
||||
"New comments": "Nye kommentarer",
|
||||
"Sort comments": "Sorter kommentarer",
|
||||
"Most recent": "Siste",
|
||||
"Order in doc": "Sortering i dokument",
|
||||
"Resolved": "Løst",
|
||||
"Show {{ count }} reply": "Vis {{ count }} svar",
|
||||
"Show {{ count }} reply_plural": "Vis {{ count }} svar",
|
||||
"Error updating comment": "Feil ved oppdatering av kommentar",
|
||||
"Document restored": "Dokument gjenopprettet",
|
||||
"Images are still uploading.\nAre you sure you want to discard them?": "Bilder lastes fortsatt opp.\nEr du sikker på at du vil forkaste dem?",
|
||||
@@ -782,7 +782,7 @@
|
||||
"This workspace has been suspended. Please contact support to restore access.": "Dette arbeidsområdet har blitt suspendert. Vennligst kontakt støtte for å gjenopprette tilgangen.",
|
||||
"Authentication failed – this login method was disabled by a team admin.": "Autentisering mislyktes – denne innloggingsmetoden ble deaktivert av en teamadministrator.",
|
||||
"The workspace you are trying to join requires an invite before you can create an account.<1></1>Please request an invite from your workspace admin and try again.": "Arbeidsområdet du prøver å bli med krever en invitasjon før du kan opprette en konto.<1></1>Vennligst be om en invitasjon fra din arbeidsområdeadministrator og prøv igjen.",
|
||||
"Sorry, an unknown error occurred.": "Sorry, an unknown error occurred.",
|
||||
"Sorry, an unknown error occurred.": "Beklager, det oppstod en ukjent feil.",
|
||||
"Login": "Logg inn",
|
||||
"Error": "Feil",
|
||||
"Failed to load configuration.": "Kunne ikke laste konfigurasjonen.",
|
||||
@@ -817,8 +817,8 @@
|
||||
"Please try again or contact support if the problem persists": "Prøv igjen eller kontakt kundestøtte hvis problemet vedvarer",
|
||||
"No documents found for your search filters.": "Ingen dokumenter funnet for søkefiltrene dine.",
|
||||
"API": "API",
|
||||
"API keys can be used to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.": "API keys can be used to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.",
|
||||
"by {{ name }}": "by {{ name }}",
|
||||
"API keys can be used to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.": "API-nøkler kan brukes til å autentisere API-et og gi programmatisk kontroll over\n dataen i ditt arbeidsområdet. For flere detaljer, se <em>utviklerdokumentasjonen</em>.",
|
||||
"by {{ name }}": "av {{ name }}",
|
||||
"Last used": "Sist brukt",
|
||||
"No expiry": "Ingen utløpsdato",
|
||||
"API key copied to clipboard": "API-nøkkel kopiert til utklippstavlen",
|
||||
@@ -869,7 +869,7 @@
|
||||
"Search people": "Søk i personer",
|
||||
"No people matching your search": "Ingen personer matcher søket ditt",
|
||||
"No people left to add": "Ingen flere personer å legge til",
|
||||
"Date created": "Date created",
|
||||
"Date created": "Dato opprettet",
|
||||
"Upload": "Last opp",
|
||||
"How does this work?": "Hvordan fungerer dette?",
|
||||
"You can import a zip file that was previously exported from the JSON option in another instance. In {{ appName }}, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.": "Du kan importere en zip-fil som tidligere ble eksportert fra JSON-alternativet i en annen instans. I {{ appName }}, åpne <em>Eksport</em> i innstillingssidestolpen og klikk på <em>Eksporter Data</em>.",
|
||||
@@ -927,7 +927,7 @@
|
||||
"Commenting": "Kommentering",
|
||||
"When enabled team members can add comments to documents.": "Når aktivert, kan teammedlemmer legge til kommentarer i dokumenter.",
|
||||
"Create a group": "Opprett en gruppe",
|
||||
"Could not load groups": "Could not load groups",
|
||||
"Could not load groups": "Kunne ikke laste grupper",
|
||||
"New group": "Ny gruppe",
|
||||
"Groups can be used to organize and manage the people on your team.": "Grupper kan brukes til å organisere og administrere personene på teamet ditt.",
|
||||
"No groups have been created yet": "Ingen grupper har blitt opprettet ennå",
|
||||
@@ -939,7 +939,7 @@
|
||||
"Import pages from a Confluence instance": "Importer sider fra en Confluence-instans",
|
||||
"Enterprise": "Bedrift",
|
||||
"Recent imports": "Nylige importer",
|
||||
"Could not load members": "Could not load members",
|
||||
"Could not load members": "Kunne ikke laste medlemmer",
|
||||
"Everyone that has signed into {{appName}} is listed here. It’s possible that there are other users who have access through {{signinMethods}} but haven’t signed in yet.": "Alle som er logget inn i {{appName}} vises her. Det er mulig at det er andre brukere som har tilgang via {{signinMethods}}, men som ikke har logget inn enda.",
|
||||
"Receive a notification whenever a new document is published": "Motta et varsel hver gang et nytt dokument blir publisert",
|
||||
"Document updated": "Dokument oppdatert",
|
||||
@@ -948,7 +948,7 @@
|
||||
"Receive a notification when a document you are subscribed to or a thread you participated in receives a comment": "Motta en varsling når et dokument du abonnerer på eller en tråd du har deltatt i mottar en kommentar",
|
||||
"Mentioned": "Nevnt",
|
||||
"Receive a notification when someone mentions you in a document or comment": "Motta en varsling når noen nevner deg i et dokument eller en kommentar",
|
||||
"Receive a notification when a comment thread you were involved in is resolved": "Receive a notification when a comment thread you were involved in is resolved",
|
||||
"Receive a notification when a comment thread you were involved in is resolved": "Motta et varsel når en kommentartråd du deltok i er løst",
|
||||
"Collection created": "Samling opprettet",
|
||||
"Receive a notification whenever a new collection is created": "Motta en varsling når en ny samling opprettes",
|
||||
"Invite accepted": "Invitasjon akseptert",
|
||||
@@ -982,8 +982,8 @@
|
||||
"When enabled, documents have a separate editing mode. When disabled, documents are always editable when you have permission.": "Når aktivert, har dokumenter en separat redigeringsmodus. Når deaktivert, er dokumenter alltid redigerbare når du har tillatelse.",
|
||||
"Remember previous location": "Husk tidligere plassering",
|
||||
"Automatically return to the document you were last viewing when the app is re-opened.": "Gå automatisk tilbake til dokumentet du sist så på når appen åpnes på nytt.",
|
||||
"Smart text replacements": "Smart text replacements",
|
||||
"Auto-format text by replacing shortcuts with symbols, dashes, smart quotes, and other typographical elements.": "Auto-format text by replacing shortcuts with symbols, dashes, smart quotes, and other typographical elements.",
|
||||
"Smart text replacements": "Smarttekst-erstatninger",
|
||||
"Auto-format text by replacing shortcuts with symbols, dashes, smart quotes, and other typographical elements.": "Autoformater tekst ved å erstatte snarveier med symboler, bindestreker, smarte anførselstegn og andre tegn.",
|
||||
"You may delete your account at any time, note that this is unrecoverable": "Du kan slette kontoen din når som helst, merk at dette er uopprettelig",
|
||||
"Profile saved": "Profil lagret",
|
||||
"Profile picture updated": "Profilbilde oppdatert",
|
||||
@@ -1022,7 +1022,7 @@
|
||||
"Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents.": "Legg til URL-en for din egen-hostede draw.io-installasjon her for å aktivere automatisk innbedning av diagrammer i dokumenter.",
|
||||
"Grist deployment": "Grist-implementering",
|
||||
"Add your self-hosted grist installation URL here.": "Legg til URL-en for din egen-hostede Grist-installasjon her.",
|
||||
"Could not load shares": "Could not load shares",
|
||||
"Could not load shares": "Kunne ikke laste delinger",
|
||||
"Sharing is currently disabled.": "Deling er for øyeblikket deaktivert.",
|
||||
"You can globally enable and disable public document sharing in the <em>security settings</em>.": "Du kan globalt aktivere og deaktivere offentlig deling av dokumenter i <em>sikkerhetsinnstillingene</em>.",
|
||||
"Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.": "Dokumenter som har blitt delt er listet nedenfor. Alle som har den offentlige lenken kan få tilgang til en skrivebeskyttet versjon av dokumentet til lenken blir tilbakekalt.",
|
||||
|
||||
+170
-158
@@ -4,95 +4,101 @@ import {
|
||||
faAndroid,
|
||||
faSquareJs,
|
||||
faPython,
|
||||
faWebAwesome,
|
||||
faXTwitter,
|
||||
faBluesky,
|
||||
} from "@fortawesome/free-brands-svg-icons";
|
||||
import {
|
||||
faHeart,
|
||||
faWandSparkles,
|
||||
faUmbrella,
|
||||
faMugHot,
|
||||
faBook,
|
||||
faDroplet,
|
||||
faBrush,
|
||||
faSnowflake,
|
||||
faShop,
|
||||
faShirt,
|
||||
faBagShopping,
|
||||
faGauge,
|
||||
faMountainSun,
|
||||
faPassport,
|
||||
faPhoneVolume,
|
||||
faNewspaper,
|
||||
faNetworkWired,
|
||||
faRocket,
|
||||
faStarOfLife,
|
||||
faSeedling,
|
||||
faTrain,
|
||||
faMicrochip,
|
||||
faRecordVinyl,
|
||||
faTrophy,
|
||||
faHammer,
|
||||
faRobot,
|
||||
faBook,
|
||||
faBrush,
|
||||
faCake,
|
||||
faCat,
|
||||
faClapperboard,
|
||||
faCompactDisc,
|
||||
faCookieBite,
|
||||
faCrow,
|
||||
faCrown,
|
||||
faCube,
|
||||
faRoad,
|
||||
faPuzzlePiece,
|
||||
faIndustry,
|
||||
faWorm,
|
||||
faVault,
|
||||
faUtensils,
|
||||
faUserGraduate,
|
||||
faUniversalAccess,
|
||||
faTractor,
|
||||
faTent,
|
||||
faSpa,
|
||||
faSocks,
|
||||
faScissors,
|
||||
faSailboat,
|
||||
faPizzaSlice,
|
||||
faPaw,
|
||||
faMap,
|
||||
faLaptopCode,
|
||||
faKitMedical,
|
||||
faFaceSurprise,
|
||||
faFaceSmileWink,
|
||||
faFaceSmileBeam,
|
||||
faFaceMeh,
|
||||
faFaceLaugh,
|
||||
faFaceGrinStars,
|
||||
faFaceDizzy,
|
||||
faDna,
|
||||
faDog,
|
||||
faCrow,
|
||||
faCompactDisc,
|
||||
faClapperboard,
|
||||
faDollarSign,
|
||||
faDisplay,
|
||||
faDroplet,
|
||||
faFaceDizzy,
|
||||
faFaceGrinStars,
|
||||
faFaceLaugh,
|
||||
faFaceMeh,
|
||||
faFaceSmileBeam,
|
||||
faFaceSmileWink,
|
||||
faFaceSurprise,
|
||||
faFeather,
|
||||
faFish,
|
||||
faCat,
|
||||
faTree,
|
||||
faShield,
|
||||
faLaptop,
|
||||
faDisplay,
|
||||
faPrescription,
|
||||
faWheelchairMove,
|
||||
faGift,
|
||||
faMagnet,
|
||||
faPaintRoller,
|
||||
faGamepad,
|
||||
faCookieBite,
|
||||
faTowerCell,
|
||||
faTooth,
|
||||
faDollarSign,
|
||||
faSterlingSign,
|
||||
faYenSign,
|
||||
faPesoSign,
|
||||
faRainbow,
|
||||
faPenRuler,
|
||||
faSwatchbook,
|
||||
faStarAndCrescent,
|
||||
faSolarPanel,
|
||||
faUmbrellaBeach,
|
||||
faGauge,
|
||||
faGem,
|
||||
faDna,
|
||||
faCake,
|
||||
faGift,
|
||||
faHammer,
|
||||
faHeart,
|
||||
faIndustry,
|
||||
faKitMedical,
|
||||
faLaptop,
|
||||
faLaptopCode,
|
||||
faMagnet,
|
||||
faMap,
|
||||
faMicrochip,
|
||||
faMountainSun,
|
||||
faMugHot,
|
||||
faNetworkWired,
|
||||
faNewspaper,
|
||||
faPaintRoller,
|
||||
faPassport,
|
||||
faPaw,
|
||||
faPenRuler,
|
||||
faPesoSign,
|
||||
faPhoneVolume,
|
||||
faPizzaSlice,
|
||||
faPrescription,
|
||||
faPuzzlePiece,
|
||||
faRainbow,
|
||||
faRecordVinyl,
|
||||
faRoad,
|
||||
faRobot,
|
||||
faRocket,
|
||||
faSailboat,
|
||||
faScissors,
|
||||
faSeedling,
|
||||
faShield,
|
||||
faShirt,
|
||||
faShop,
|
||||
faSnowflake,
|
||||
faSocks,
|
||||
faSolarPanel,
|
||||
faSpa,
|
||||
faStarAndCrescent,
|
||||
faStarOfLife,
|
||||
faSterlingSign,
|
||||
faSwatchbook,
|
||||
faTent,
|
||||
faTooth,
|
||||
faTowerCell,
|
||||
faTractor,
|
||||
faTrain,
|
||||
faTree,
|
||||
faTrophy,
|
||||
faUmbrella,
|
||||
faUmbrellaBeach,
|
||||
faUniversalAccess,
|
||||
faUserGraduate,
|
||||
faUtensils,
|
||||
faVault,
|
||||
faWandSparkles,
|
||||
faWheelchairMove,
|
||||
faWorm,
|
||||
faYenSign,
|
||||
faHandsClapping,
|
||||
faFolderClosed,
|
||||
faFlaskVial,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import intersection from "lodash/intersection";
|
||||
@@ -447,98 +453,104 @@ export class IconLibrary {
|
||||
// Font awesome
|
||||
...Object.fromEntries(
|
||||
[
|
||||
faHeart,
|
||||
faWandSparkles,
|
||||
faUmbrella,
|
||||
faMugHot,
|
||||
faBook,
|
||||
faDroplet,
|
||||
faBrush,
|
||||
faSnowflake,
|
||||
faShop,
|
||||
faShirt,
|
||||
faBagShopping,
|
||||
faGauge,
|
||||
faMountainSun,
|
||||
faPassport,
|
||||
faPhoneVolume,
|
||||
faNewspaper,
|
||||
faNetworkWired,
|
||||
faRocket,
|
||||
faStarOfLife,
|
||||
faSeedling,
|
||||
faTrain,
|
||||
faMicrochip,
|
||||
faRecordVinyl,
|
||||
faTrophy,
|
||||
faHammer,
|
||||
faRobot,
|
||||
faBook,
|
||||
faBrush,
|
||||
faCake,
|
||||
faCat,
|
||||
faClapperboard,
|
||||
faCompactDisc,
|
||||
faCookieBite,
|
||||
faCrow,
|
||||
faCrown,
|
||||
faCube,
|
||||
faRoad,
|
||||
faPuzzlePiece,
|
||||
faIndustry,
|
||||
faWorm,
|
||||
faVault,
|
||||
faUtensils,
|
||||
faUserGraduate,
|
||||
faUniversalAccess,
|
||||
faTractor,
|
||||
faTent,
|
||||
faSpa,
|
||||
faSocks,
|
||||
faScissors,
|
||||
faSailboat,
|
||||
faPizzaSlice,
|
||||
faPaw,
|
||||
faMap,
|
||||
faLaptopCode,
|
||||
faKitMedical,
|
||||
faFaceSurprise,
|
||||
faFaceSmileWink,
|
||||
faFaceSmileBeam,
|
||||
faFaceMeh,
|
||||
faFaceLaugh,
|
||||
faFaceGrinStars,
|
||||
faFaceDizzy,
|
||||
faDna,
|
||||
faDog,
|
||||
faCrow,
|
||||
faCompactDisc,
|
||||
faClapperboard,
|
||||
faDollarSign,
|
||||
faDisplay,
|
||||
faDroplet,
|
||||
faFaceDizzy,
|
||||
faFaceGrinStars,
|
||||
faFaceLaugh,
|
||||
faFaceMeh,
|
||||
faFaceSmileBeam,
|
||||
faFaceSmileWink,
|
||||
faFaceSurprise,
|
||||
faFeather,
|
||||
faFish,
|
||||
faCat,
|
||||
faTree,
|
||||
faShield,
|
||||
faLaptop,
|
||||
faDisplay,
|
||||
faPrescription,
|
||||
faWheelchairMove,
|
||||
faGift,
|
||||
faMagnet,
|
||||
faPaintRoller,
|
||||
faFolderClosed,
|
||||
faFlaskVial,
|
||||
faGamepad,
|
||||
faCookieBite,
|
||||
faTowerCell,
|
||||
faTooth,
|
||||
faDollarSign,
|
||||
faSterlingSign,
|
||||
faYenSign,
|
||||
faPesoSign,
|
||||
faRainbow,
|
||||
faPenRuler,
|
||||
faSwatchbook,
|
||||
faStarAndCrescent,
|
||||
faSolarPanel,
|
||||
faUmbrellaBeach,
|
||||
faGauge,
|
||||
faGem,
|
||||
faDna,
|
||||
faCake,
|
||||
faGift,
|
||||
faHammer,
|
||||
faHandsClapping,
|
||||
faHeart,
|
||||
faIndustry,
|
||||
faKitMedical,
|
||||
faLaptop,
|
||||
faLaptopCode,
|
||||
faMagnet,
|
||||
faMap,
|
||||
faMicrochip,
|
||||
faMountainSun,
|
||||
faMugHot,
|
||||
faNetworkWired,
|
||||
faNewspaper,
|
||||
faPaintRoller,
|
||||
faPassport,
|
||||
faPaw,
|
||||
faPenRuler,
|
||||
faPesoSign,
|
||||
faPhoneVolume,
|
||||
faPizzaSlice,
|
||||
faPrescription,
|
||||
faPuzzlePiece,
|
||||
faRainbow,
|
||||
faRecordVinyl,
|
||||
faRoad,
|
||||
faRobot,
|
||||
faRocket,
|
||||
faSailboat,
|
||||
faScissors,
|
||||
faSeedling,
|
||||
faShield,
|
||||
faShirt,
|
||||
faShop,
|
||||
faSnowflake,
|
||||
faSocks,
|
||||
faSolarPanel,
|
||||
faSpa,
|
||||
faStarAndCrescent,
|
||||
faStarOfLife,
|
||||
faSterlingSign,
|
||||
faSwatchbook,
|
||||
faTent,
|
||||
faTooth,
|
||||
faTowerCell,
|
||||
faTractor,
|
||||
faTrain,
|
||||
faTree,
|
||||
faTrophy,
|
||||
faUmbrella,
|
||||
faUmbrellaBeach,
|
||||
faUniversalAccess,
|
||||
faUserGraduate,
|
||||
faUtensils,
|
||||
faVault,
|
||||
faWandSparkles,
|
||||
faWebAwesome,
|
||||
faWheelchairMove,
|
||||
faWorm,
|
||||
faYenSign,
|
||||
faApple,
|
||||
faWindows,
|
||||
faAndroid,
|
||||
faSquareJs,
|
||||
faPython,
|
||||
faXTwitter,
|
||||
faBluesky,
|
||||
].map((icon) => [
|
||||
icon.iconName,
|
||||
{
|
||||
|
||||
+4
-1
@@ -2,13 +2,14 @@ import fs from "fs";
|
||||
import path from "path";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import browserslistToEsbuild from "browserslist-to-esbuild";
|
||||
import { webpackStats } from "rollup-plugin-webpack-stats";
|
||||
import webpackStats from "rollup-plugin-webpack-stats";
|
||||
import { CommonServerOptions, defineConfig } from "vite";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import { viteStaticCopy } from "vite-plugin-static-copy";
|
||||
import environment from "./server/utils/environment";
|
||||
|
||||
let httpsConfig: CommonServerOptions["https"] | undefined;
|
||||
const host = new URL(environment.URL!).hostname;
|
||||
|
||||
if (environment.NODE_ENV === "development") {
|
||||
try {
|
||||
@@ -31,6 +32,8 @@ export default () =>
|
||||
port: 3001,
|
||||
host: true,
|
||||
https: httpsConfig,
|
||||
allowedHosts: [host],
|
||||
cors: true,
|
||||
fs:
|
||||
environment.NODE_ENV === "development"
|
||||
? {
|
||||
|
||||
Reference in New Issue
Block a user