feat: Improve settings layout (#3234)
* Setup, and security settings * Settings -> Details * Settings -> Notifications * Profile * lint * fix: Flash of loading on members screen * align language input * feat: Move share links management to sortable table * Add account menu to sidebar on settings page * Aesthetic tweaks, light borders between settings and slight column offset
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { HomeIcon } from "outline-icons";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Optional } from "utility-types";
|
||||
import CollectionIcon from "~/components/CollectionIcon";
|
||||
import Flex from "~/components/Flex";
|
||||
import InputSelect from "~/components/InputSelect";
|
||||
@@ -8,7 +9,9 @@ import { IconWrapper } from "~/components/Sidebar/components/SidebarLink";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type DefaultCollectionInputSelectProps = {
|
||||
type DefaultCollectionInputSelectProps = Optional<
|
||||
React.ComponentProps<typeof InputSelect>
|
||||
> & {
|
||||
onSelectCollection: (collection: string) => void;
|
||||
defaultCollectionId: string | null;
|
||||
};
|
||||
@@ -16,6 +19,7 @@ type DefaultCollectionInputSelectProps = {
|
||||
const DefaultCollectionInputSelect = ({
|
||||
onSelectCollection,
|
||||
defaultCollectionId,
|
||||
...rest
|
||||
}: DefaultCollectionInputSelectProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { collections } = useStores();
|
||||
@@ -88,14 +92,11 @@ const DefaultCollectionInputSelect = ({
|
||||
return (
|
||||
<InputSelect
|
||||
value={defaultCollectionId ?? "home"}
|
||||
label={t("Start view")}
|
||||
options={options}
|
||||
onChange={onSelectCollection}
|
||||
ariaLabel={t("Default collection")}
|
||||
note={t(
|
||||
"This is the screen that team members will first see when they sign in."
|
||||
)}
|
||||
short
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -97,7 +97,7 @@ export const LabelText = styled.div`
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
export type Props = {
|
||||
export type Props = React.HTMLAttributes<HTMLInputElement> & {
|
||||
type?: "text" | "email" | "checkbox" | "search" | "textarea";
|
||||
value?: string;
|
||||
label?: string;
|
||||
|
||||
@@ -23,6 +23,8 @@ export type Option = {
|
||||
};
|
||||
|
||||
export type Props = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: string | null;
|
||||
label?: string;
|
||||
nude?: boolean;
|
||||
@@ -54,6 +56,7 @@ const InputSelect = (props: Props) => {
|
||||
disabled,
|
||||
note,
|
||||
icon,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const select = useSelectState({
|
||||
@@ -128,7 +131,7 @@ const InputSelect = (props: Props) => {
|
||||
wrappedLabel
|
||||
))}
|
||||
|
||||
<Select {...select} disabled={disabled} ref={buttonRef}>
|
||||
<Select {...select} disabled={disabled} {...rest} ref={buttonRef}>
|
||||
{(props) => (
|
||||
<StyledButton
|
||||
neutral
|
||||
@@ -229,6 +232,7 @@ const StyledButton = styled(Button)<{ nude?: boolean }>`
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
cursor: default;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: ${(props) => props.theme.buttonNeutralBackground};
|
||||
|
||||
@@ -49,6 +49,7 @@ function Scrollable(
|
||||
React.useEffect(() => {
|
||||
updateShadows();
|
||||
}, [height, updateShadows]);
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
ref={ref || fallbackRef}
|
||||
|
||||
@@ -10,10 +10,8 @@ import Scrollable from "~/components/Scrollable";
|
||||
import Text from "~/components/Text";
|
||||
import { inviteUser } from "~/actions/definitions/users";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import AccountMenu from "~/menus/AccountMenu";
|
||||
import OrganizationMenu from "~/menus/OrganizationMenu";
|
||||
import {
|
||||
homePath,
|
||||
@@ -21,7 +19,6 @@ import {
|
||||
templatesPath,
|
||||
searchPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import Avatar from "../Avatar";
|
||||
import TeamLogo from "../TeamLogo";
|
||||
import Sidebar from "./Sidebar";
|
||||
import ArchiveLink from "./components/ArchiveLink";
|
||||
@@ -37,7 +34,6 @@ function AppSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const can = usePolicy(team.id);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -128,22 +124,6 @@ function AppSidebar() {
|
||||
<SidebarAction action={inviteUser} />
|
||||
</Section>
|
||||
</Scrollable>
|
||||
<AccountMenu>
|
||||
{(props) => (
|
||||
<SidebarButton
|
||||
{...props}
|
||||
showMoreMenu
|
||||
title={user.name}
|
||||
image={
|
||||
<StyledAvatar
|
||||
src={user.avatarUrl}
|
||||
size={24}
|
||||
showBorder={false}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</AccountMenu>
|
||||
</DndProvider>
|
||||
)}
|
||||
</Sidebar>
|
||||
@@ -154,10 +134,6 @@ const StyledTeamLogo = styled(TeamLogo)`
|
||||
margin-right: 4px;
|
||||
`;
|
||||
|
||||
const StyledAvatar = styled(Avatar)`
|
||||
margin-left: 4px;
|
||||
`;
|
||||
|
||||
const Drafts = styled(Text)`
|
||||
margin: 0 4px;
|
||||
`;
|
||||
|
||||
@@ -9,8 +9,6 @@ import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import env from "~/env";
|
||||
import useAuthorizedSettingsConfig from "~/hooks/useAuthorizedSettingsConfig";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import Sidebar from "./Sidebar";
|
||||
import Header from "./components/Header";
|
||||
import Section from "./components/Section";
|
||||
@@ -23,8 +21,6 @@ const isHosted = env.DEPLOYMENT === "hosted";
|
||||
function SettingsSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(team.id);
|
||||
const configs = useAuthorizedSettingsConfig();
|
||||
const groupedConfig = groupBy(configs, "group");
|
||||
|
||||
@@ -42,7 +38,7 @@ function SettingsSidebar() {
|
||||
/>
|
||||
|
||||
<Flex auto column>
|
||||
<Scrollable topShadow>
|
||||
<Scrollable shadow>
|
||||
{Object.keys(groupedConfig).map((header) => (
|
||||
<Section key={header}>
|
||||
<Header>{header}</Header>
|
||||
@@ -56,7 +52,7 @@ function SettingsSidebar() {
|
||||
))}
|
||||
</Section>
|
||||
))}
|
||||
{can.update && !isHosted && (
|
||||
{!isHosted && (
|
||||
<Section>
|
||||
<Header>{t("Installation")}</Header>
|
||||
<Version />
|
||||
|
||||
@@ -6,11 +6,15 @@ import { useLocation } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Flex from "~/components/Flex";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useMenuContext from "~/hooks/useMenuContext";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import AccountMenu from "~/menus/AccountMenu";
|
||||
import { fadeIn } from "~/styles/animations";
|
||||
import Avatar from "../Avatar";
|
||||
import ResizeBorder from "./components/ResizeBorder";
|
||||
import SidebarButton from "./components/SidebarButton";
|
||||
import Toggle, { ToggleButton, Positioner } from "./components/Toggle";
|
||||
|
||||
const ANIMATION_MS = 250;
|
||||
@@ -28,6 +32,8 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
|
||||
const location = useLocation();
|
||||
const previousLocation = usePrevious(location);
|
||||
const { isMenuOpen } = useMenuContext();
|
||||
const user = useCurrentUser();
|
||||
|
||||
const width = ui.sidebarWidth;
|
||||
const collapsed = (ui.isEditing || ui.sidebarCollapsed) && !isMenuOpen;
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
@@ -162,6 +168,23 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
|
||||
</Portal>
|
||||
)}
|
||||
{children}
|
||||
|
||||
<AccountMenu>
|
||||
{(props) => (
|
||||
<SidebarButton
|
||||
{...props}
|
||||
showMoreMenu
|
||||
title={user.name}
|
||||
image={
|
||||
<StyledAvatar
|
||||
src={user.avatarUrl}
|
||||
size={24}
|
||||
showBorder={false}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</AccountMenu>
|
||||
<ResizeBorder
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={ui.sidebarCollapsed ? undefined : handleReset}
|
||||
@@ -187,6 +210,10 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
|
||||
}
|
||||
);
|
||||
|
||||
const StyledAvatar = styled(Avatar)`
|
||||
margin-left: 4px;
|
||||
`;
|
||||
|
||||
const Backdrop = styled.a`
|
||||
animation: ${fadeIn} 250ms ease-in-out;
|
||||
position: fixed;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useTable, useSortBy, usePagination } from "react-table";
|
||||
import styled from "styled-components";
|
||||
import Button from "~/components/Button";
|
||||
import DelayedMount from "~/components/DelayedMount";
|
||||
import Empty from "~/components/Empty";
|
||||
import Flex from "~/components/Flex";
|
||||
import PlaceholderText from "~/components/PlaceholderText";
|
||||
@@ -121,7 +122,11 @@ function Table({
|
||||
<tr {...headerGroup.getHeaderGroupProps()}>
|
||||
{headerGroup.headers.map((column) => (
|
||||
<Head {...column.getHeaderProps(column.getSortByToggleProps())}>
|
||||
<SortWrapper align="center" gap={4}>
|
||||
<SortWrapper
|
||||
align="center"
|
||||
$sortable={!column.disableSortBy}
|
||||
gap={4}
|
||||
>
|
||||
{column.render("Header")}
|
||||
{column.isSorted &&
|
||||
(column.isSortedDesc ? (
|
||||
@@ -190,17 +195,19 @@ export const Placeholder = ({
|
||||
rows?: number;
|
||||
}) => {
|
||||
return (
|
||||
<tbody>
|
||||
{new Array(rows).fill(1).map((_, row) => (
|
||||
<Row key={row}>
|
||||
{new Array(columns).fill(1).map((_, col) => (
|
||||
<Cell key={col}>
|
||||
<PlaceholderText minWidth={25} maxWidth={75} />
|
||||
</Cell>
|
||||
))}
|
||||
</Row>
|
||||
))}
|
||||
</tbody>
|
||||
<DelayedMount>
|
||||
<tbody>
|
||||
{new Array(rows).fill(1).map((_, row) => (
|
||||
<Row key={row}>
|
||||
{new Array(columns).fill(1).map((_, col) => (
|
||||
<Cell key={col}>
|
||||
<PlaceholderText minWidth={25} maxWidth={75} />
|
||||
</Cell>
|
||||
))}
|
||||
</Row>
|
||||
))}
|
||||
</tbody>
|
||||
</DelayedMount>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -214,6 +221,8 @@ const Pagination = styled(Flex)`
|
||||
`;
|
||||
|
||||
const DescSortIcon = styled(CollapsedIcon)`
|
||||
margin-left: -2px;
|
||||
|
||||
&:hover {
|
||||
fill: ${(props) => props.theme.text};
|
||||
}
|
||||
@@ -229,12 +238,22 @@ const InnerTable = styled.table`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const SortWrapper = styled(Flex)`
|
||||
const SortWrapper = styled(Flex)<{ $sortable: boolean }>`
|
||||
display: inline-flex;
|
||||
height: 24px;
|
||||
user-select: none;
|
||||
border-radius: 4px;
|
||||
margin: 0 -4px;
|
||||
padding: 0 4px;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) =>
|
||||
props.$sortable ? props.theme.secondaryBackground : "none"};
|
||||
}
|
||||
`;
|
||||
|
||||
const Cell = styled.td`
|
||||
padding: 6px;
|
||||
padding: 8px 6px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.divider};
|
||||
font-size: 14px;
|
||||
|
||||
|
||||
75
app/components/TableFromParams.tsx
Normal file
75
app/components/TableFromParams.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import * as React from "react";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import type { Props } from "./Table";
|
||||
|
||||
const Table = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "table" */
|
||||
"~/components/Table"
|
||||
)
|
||||
);
|
||||
|
||||
const TableFromParams = (
|
||||
props: Omit<Props, "onChangeSort" | "onChangePage" | "topRef">
|
||||
) => {
|
||||
const topRef = React.useRef();
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const params = useQuery();
|
||||
|
||||
const handleChangeSort = React.useCallback(
|
||||
(sort, direction) => {
|
||||
if (sort) {
|
||||
params.set("sort", sort);
|
||||
} else {
|
||||
params.delete("sort");
|
||||
}
|
||||
|
||||
params.set("direction", direction.toLowerCase());
|
||||
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
});
|
||||
},
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
const handleChangePage = React.useCallback(
|
||||
(page) => {
|
||||
if (page) {
|
||||
params.set("page", page.toString());
|
||||
} else {
|
||||
params.delete("page");
|
||||
}
|
||||
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
});
|
||||
|
||||
if (topRef.current) {
|
||||
scrollIntoView(topRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "auto",
|
||||
block: "start",
|
||||
});
|
||||
}
|
||||
},
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
return (
|
||||
<Table
|
||||
topRef={topRef}
|
||||
onChangeSort={handleChangeSort}
|
||||
onChangePage={handleChangePage}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableFromParams;
|
||||
@@ -2,7 +2,7 @@ import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
type?: "secondary" | "tertiary";
|
||||
size?: "small" | "xsmall";
|
||||
size?: "large" | "small" | "xsmall";
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -18,7 +18,9 @@ const Text = styled.p<Props>`
|
||||
? props.theme.textTertiary
|
||||
: props.theme.text};
|
||||
font-size: ${(props) =>
|
||||
props.size === "small"
|
||||
props.size === "large"
|
||||
? "18px"
|
||||
: props.size === "small"
|
||||
? "14px"
|
||||
: props.size === "xsmall"
|
||||
? "13px"
|
||||
|
||||
@@ -14,6 +14,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import ImageInput from "./components/ImageInput";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
|
||||
function Details() {
|
||||
const { auth } = useStores();
|
||||
@@ -89,60 +90,87 @@ function Details() {
|
||||
setDefaultCollectionId(defaultCollectionId);
|
||||
}, []);
|
||||
|
||||
const isValid = form.current && form.current.checkValidity();
|
||||
const isValid = form.current?.checkValidity();
|
||||
|
||||
return (
|
||||
<Scene title={t("Details")} icon={<TeamIcon color="currentColor" />}>
|
||||
<Heading>{t("Details")}</Heading>
|
||||
<Text type="secondary">
|
||||
<Trans>
|
||||
These details affect the way that your Outline appears to everyone on
|
||||
the team.
|
||||
These settings affect the way that your knowledge base appears to
|
||||
everyone on the team.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<ImageInput
|
||||
label={t("Logo")}
|
||||
onSuccess={handleAvatarUpload}
|
||||
onError={handleAvatarError}
|
||||
src={avatarUrl}
|
||||
borderRadius={0}
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSubmit} ref={form}>
|
||||
<Input
|
||||
<SettingRow
|
||||
label={t("Logo")}
|
||||
name="avatarUrl"
|
||||
description={t(
|
||||
"The logo is displayed at the top left of the application."
|
||||
)}
|
||||
>
|
||||
<ImageInput
|
||||
onSuccess={handleAvatarUpload}
|
||||
onError={handleAvatarError}
|
||||
src={avatarUrl}
|
||||
borderRadius={0}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("Name")}
|
||||
name="name"
|
||||
autoComplete="organization"
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
required
|
||||
short
|
||||
/>
|
||||
{env.SUBDOMAINS_ENABLED && (
|
||||
<>
|
||||
<Input
|
||||
label={t("Subdomain")}
|
||||
name="subdomain"
|
||||
value={subdomain || ""}
|
||||
onChange={handleSubdomainChange}
|
||||
autoComplete="off"
|
||||
minLength={4}
|
||||
maxLength={32}
|
||||
short
|
||||
/>
|
||||
{subdomain && (
|
||||
description={t(
|
||||
"The team name, usually the same as your company name."
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
id="name"
|
||||
autoComplete="organization"
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
required
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
visible={env.SUBDOMAINS_ENABLED && env.DEPLOYMENT === "hosted"}
|
||||
label={t("Subdomain")}
|
||||
name="subdomain"
|
||||
description={
|
||||
subdomain ? (
|
||||
<Text type="secondary" size="small">
|
||||
<Trans>Your knowledge base will be accessible at</Trans>{" "}
|
||||
<strong>{subdomain}.getoutline.com</strong>
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<DefaultCollectionInputSelect
|
||||
onSelectCollection={onSelectCollection}
|
||||
defaultCollectionId={defaultCollectionId}
|
||||
/>
|
||||
) : (
|
||||
t("Choose a subdomain to enable a login page just for your team.")
|
||||
)
|
||||
}
|
||||
>
|
||||
<Input
|
||||
id="subdomain"
|
||||
value={subdomain || ""}
|
||||
onChange={handleSubdomainChange}
|
||||
autoComplete="off"
|
||||
minLength={4}
|
||||
maxLength={32}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
border={false}
|
||||
label={t("Start view")}
|
||||
name="defaultCollectionId"
|
||||
description={t(
|
||||
"This is the screen that team members will first see when they sign in."
|
||||
)}
|
||||
>
|
||||
<DefaultCollectionInputSelect
|
||||
id="defaultCollectionId"
|
||||
onSelectCollection={onSelectCollection}
|
||||
defaultCollectionId={defaultCollectionId}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<Button type="submit" disabled={auth.isSaving || !isValid}>
|
||||
{auth.isSaving ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
|
||||
@@ -7,7 +7,6 @@ import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import Scene from "~/components/Scene";
|
||||
import Subheading from "~/components/Subheading";
|
||||
import Text from "~/components/Text";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -86,9 +85,9 @@ function Export() {
|
||||
type: "export",
|
||||
}}
|
||||
heading={
|
||||
<Subheading>
|
||||
<h2>
|
||||
<Trans>Recent exports</Trans>
|
||||
</Subheading>
|
||||
</h2>
|
||||
}
|
||||
renderItem={(item) => (
|
||||
<FileOperationListItem
|
||||
|
||||
@@ -10,6 +10,7 @@ import Text from "~/components/Text";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
|
||||
function Features() {
|
||||
const { auth } = useStores();
|
||||
@@ -35,27 +36,27 @@ function Features() {
|
||||
|
||||
return (
|
||||
<Scene title={t("Features")} icon={<BeakerIcon color="currentColor" />}>
|
||||
<Heading>
|
||||
<Trans>Features</Trans>
|
||||
</Heading>
|
||||
<Heading>{t("Features")}</Heading>
|
||||
<Text type="secondary">
|
||||
<Trans>
|
||||
Manage optional and beta features. Changing these settings will affect
|
||||
the experience for all team members.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Switch
|
||||
label={t("Collaborative editing")}
|
||||
<SettingRow
|
||||
name="collaborativeEditing"
|
||||
checked={data.collaborativeEditing}
|
||||
onChange={handleChange}
|
||||
note={
|
||||
<Trans>
|
||||
When enabled multiple people can edit documents at the same time
|
||||
with shared presence and live cursors.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
label={t("Collaborative editing")}
|
||||
description={t(
|
||||
"When enabled multiple people can edit documents at the same time with shared presence and live cursors."
|
||||
)}
|
||||
>
|
||||
<Switch
|
||||
id="collaborativeEditing"
|
||||
checked={data.collaborativeEditing}
|
||||
disabled={data.collaborativeEditing}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</SettingRow>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import Item from "~/components/List/Item";
|
||||
import OutlineLogo from "~/components/OutlineLogo";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import Scene from "~/components/Scene";
|
||||
import Subheading from "~/components/Subheading";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
@@ -139,9 +138,9 @@ function Import() {
|
||||
type: "import",
|
||||
}}
|
||||
heading={
|
||||
<Subheading>
|
||||
<h2>
|
||||
<Trans>Recent imports</Trans>
|
||||
</Subheading>
|
||||
</h2>
|
||||
}
|
||||
renderItem={(item) => (
|
||||
<FileOperationListItem key={item.id} fileOperation={item} />
|
||||
|
||||
@@ -4,7 +4,6 @@ import { PlusIcon, UserIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import styled from "styled-components";
|
||||
import { PAGINATION_SYMBOL } from "~/stores/BaseStore";
|
||||
import User from "~/models/User";
|
||||
@@ -26,7 +25,6 @@ import PeopleTable from "./components/PeopleTable";
|
||||
import UserStatusFilter from "./components/UserStatusFilter";
|
||||
|
||||
function Members() {
|
||||
const topRef = React.useRef();
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const [
|
||||
@@ -46,9 +44,9 @@ function Members() {
|
||||
const query = params.get("query") || "";
|
||||
const filter = params.get("filter") || "";
|
||||
const sort = params.get("sort") || "name";
|
||||
const direction =
|
||||
params.get("direction")?.toUpperCase() === "ASC" ? "ASC" : "DESC";
|
||||
|
||||
const direction = (params.get("direction") || "asc").toUpperCase() as
|
||||
| "ASC"
|
||||
| "DESC";
|
||||
const page = parseInt(params.get("page") || "0", 10);
|
||||
const limit = 25;
|
||||
|
||||
@@ -141,52 +139,6 @@ function Members() {
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
const handleChangeSort = React.useCallback(
|
||||
(sort, direction) => {
|
||||
if (sort) {
|
||||
params.set("sort", sort);
|
||||
} else {
|
||||
params.delete("sort");
|
||||
}
|
||||
|
||||
if (direction === "DESC") {
|
||||
params.set("direction", direction.toLowerCase());
|
||||
} else {
|
||||
params.delete("direction");
|
||||
}
|
||||
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
});
|
||||
},
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
const handleChangePage = React.useCallback(
|
||||
(page) => {
|
||||
if (page) {
|
||||
params.set("page", page.toString());
|
||||
} else {
|
||||
params.delete("page");
|
||||
}
|
||||
|
||||
history.replace({
|
||||
pathname: location.pathname,
|
||||
search: params.toString(),
|
||||
});
|
||||
|
||||
if (topRef.current) {
|
||||
scrollIntoView(topRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "auto",
|
||||
block: "start",
|
||||
});
|
||||
}
|
||||
},
|
||||
[params, history, location.pathname]
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
title={t("Members")}
|
||||
@@ -228,13 +180,11 @@ function Members() {
|
||||
<LargeUserStatusFilter activeKey={filter} onSelect={handleFilter} />
|
||||
</Flex>
|
||||
<PeopleTable
|
||||
topRef={topRef}
|
||||
data={data}
|
||||
canManage={can.manage}
|
||||
isLoading={isLoading}
|
||||
onChangeSort={handleChangeSort}
|
||||
onChangePage={handleChangePage}
|
||||
page={page}
|
||||
pageSize={limit}
|
||||
totalPages={totalPages}
|
||||
defaultSortDirection="ASC"
|
||||
/>
|
||||
|
||||
@@ -3,18 +3,17 @@ import { observer } from "mobx-react";
|
||||
import { EmailIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Heading from "~/components/Heading";
|
||||
import Input from "~/components/Input";
|
||||
import Notice from "~/components/Notice";
|
||||
import Scene from "~/components/Scene";
|
||||
import Subheading from "~/components/Subheading";
|
||||
import Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import NotificationListItem from "./components/NotificationListItem";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
|
||||
function Notifications() {
|
||||
const { notificationSettings } = useStores();
|
||||
@@ -48,6 +47,7 @@ function Notifications() {
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
visible: env.DEPLOYMENT === "hosted",
|
||||
event: "emails.onboarding",
|
||||
title: t("Getting started"),
|
||||
description: t(
|
||||
@@ -55,6 +55,7 @@ function Notifications() {
|
||||
),
|
||||
},
|
||||
{
|
||||
visible: env.DEPLOYMENT === "hosted",
|
||||
event: "emails.features",
|
||||
title: t("New features"),
|
||||
description: t("Receive an email when new features of note are added"),
|
||||
@@ -101,42 +102,49 @@ function Notifications() {
|
||||
</Notice>
|
||||
)}
|
||||
<Text type="secondary">
|
||||
<Trans>
|
||||
Manage when and where you receive email notifications from Outline.
|
||||
Your email address can be updated in your SSO provider.
|
||||
</Trans>
|
||||
<Trans>Manage when and where you receive email notifications.</Trans>
|
||||
</Text>
|
||||
|
||||
{env.EMAIL_ENABLED ? (
|
||||
<>
|
||||
<Input
|
||||
type="email"
|
||||
value={user.email}
|
||||
<SettingRow
|
||||
label={t("Email address")}
|
||||
readOnly
|
||||
short
|
||||
/>
|
||||
name="email"
|
||||
description={t(
|
||||
"Your email address should be updated in your SSO provider."
|
||||
)}
|
||||
>
|
||||
<Input type="email" value={user.email} readOnly />
|
||||
</SettingRow>
|
||||
|
||||
<Subheading>{t("Notifications")}</Subheading>
|
||||
<h2>{t("Notifications")}</h2>
|
||||
|
||||
{options.map((option, index) => {
|
||||
{options.map((option) => {
|
||||
if (option.separator || !option.event) {
|
||||
return <Separator key={`separator-${index}`} />;
|
||||
return <br />;
|
||||
}
|
||||
|
||||
const setting = notificationSettings.getByEvent(option.event);
|
||||
|
||||
return (
|
||||
<NotificationListItem
|
||||
key={option.event}
|
||||
onChange={handleChange}
|
||||
setting={setting}
|
||||
disabled={
|
||||
(setting && setting.isSaving) ||
|
||||
notificationSettings.isFetching
|
||||
}
|
||||
{...option}
|
||||
/>
|
||||
<SettingRow
|
||||
visible={option.visible}
|
||||
label={option.title}
|
||||
name={option.event}
|
||||
description={option.description}
|
||||
>
|
||||
<Switch
|
||||
key={option.event}
|
||||
id={option.event}
|
||||
name={option.event}
|
||||
checked={!!setting}
|
||||
onChange={handleChange}
|
||||
disabled={
|
||||
(setting && setting.isSaving) ||
|
||||
notificationSettings.isFetching
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
@@ -153,8 +161,4 @@ function Notifications() {
|
||||
);
|
||||
}
|
||||
|
||||
const Separator = styled.hr`
|
||||
padding-bottom: 12px;
|
||||
`;
|
||||
|
||||
export default observer(Notifications);
|
||||
|
||||
@@ -15,6 +15,7 @@ import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import ImageInput from "./components/ImageInput";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
|
||||
const Profile = () => {
|
||||
const { auth } = useStores();
|
||||
@@ -74,51 +75,71 @@ const Profile = () => {
|
||||
setShowDeleteModal((prev) => !prev);
|
||||
};
|
||||
|
||||
const isValid = form.current && form.current.checkValidity();
|
||||
const isValid = form.current?.checkValidity();
|
||||
const { isSaving } = auth;
|
||||
|
||||
return (
|
||||
<Scene title={t("Profile")} icon={<ProfileIcon color="currentColor" />}>
|
||||
<Heading>{t("Profile")}</Heading>
|
||||
|
||||
<ImageInput
|
||||
label={t("Photo")}
|
||||
onSuccess={handleAvatarUpload}
|
||||
onError={handleAvatarError}
|
||||
src={avatarUrl}
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSubmit} ref={form}>
|
||||
<Input
|
||||
<SettingRow
|
||||
label={t("Photo")}
|
||||
name="avatarUrl"
|
||||
description={t("Choose a photo or image to represent yourself.")}
|
||||
>
|
||||
<ImageInput
|
||||
onSuccess={handleAvatarUpload}
|
||||
onError={handleAvatarError}
|
||||
src={avatarUrl}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("Full name")}
|
||||
autoComplete="name"
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
required
|
||||
short
|
||||
/>
|
||||
<InputSelect
|
||||
name="name"
|
||||
description={t(
|
||||
"This could be your real name, or a nickname — however you’d like people to refer to you."
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
id="name"
|
||||
autoComplete="name"
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
required
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
border={false}
|
||||
label={t("Language")}
|
||||
options={languageOptions}
|
||||
value={language}
|
||||
onChange={handleLanguageChange}
|
||||
ariaLabel={t("Language")}
|
||||
note={
|
||||
<Trans>
|
||||
Please note that translations are currently in early access.
|
||||
<br />
|
||||
Community contributions are accepted though our{" "}
|
||||
<a
|
||||
href="https://translate.getoutline.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
translation portal
|
||||
</a>
|
||||
</Trans>
|
||||
name="language"
|
||||
description={
|
||||
<>
|
||||
<Trans>
|
||||
Please note that translations are currently in early access.
|
||||
Community contributions are accepted though our{" "}
|
||||
<a
|
||||
href="https://translate.getoutline.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
translation portal
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
</>
|
||||
}
|
||||
short
|
||||
/>
|
||||
>
|
||||
<InputSelect
|
||||
id="language"
|
||||
options={languageOptions}
|
||||
value={language}
|
||||
onChange={handleLanguageChange}
|
||||
ariaLabel={t("Language")}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<Button type="submit" disabled={isSaving || !isValid}>
|
||||
{isSaving ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
@@ -126,7 +147,7 @@ const Profile = () => {
|
||||
|
||||
<DangerZone>
|
||||
<h2>{t("Delete Account")}</h2>
|
||||
<Text type="secondary" size="small">
|
||||
<Text type="secondary">
|
||||
<Trans>
|
||||
You may delete your account at any time, note that this is
|
||||
unrecoverable
|
||||
|
||||
@@ -13,6 +13,7 @@ import env from "~/env";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
|
||||
function Security() {
|
||||
const { auth } = useStores();
|
||||
@@ -25,10 +26,6 @@ function Security() {
|
||||
guestSignin: team.guestSignin,
|
||||
defaultUserRole: team.defaultUserRole,
|
||||
});
|
||||
const notes = {
|
||||
member: t("New user accounts will be given member permissions by default"),
|
||||
viewer: t("New user accounts will be given viewer permissions by default"),
|
||||
};
|
||||
|
||||
const showSuccessMessage = React.useMemo(
|
||||
() =>
|
||||
@@ -42,7 +39,7 @@ function Security() {
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
async (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newData = { ...data, [ev.target.name]: ev.target.checked };
|
||||
const newData = { ...data, [ev.target.id]: ev.target.checked };
|
||||
setData(newData);
|
||||
await auth.updateTeam(newData);
|
||||
showSuccessMessage();
|
||||
@@ -59,9 +56,7 @@ function Security() {
|
||||
|
||||
return (
|
||||
<Scene title={t("Security")} icon={<PadlockIcon color="currentColor" />}>
|
||||
<Heading>
|
||||
<Trans>Security</Trans>
|
||||
</Heading>
|
||||
<Heading>{t("Security")}</Heading>
|
||||
<Text type="secondary">
|
||||
<Trans>
|
||||
Settings that impact the access, security, and content of your
|
||||
@@ -69,54 +64,70 @@ function Security() {
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Switch
|
||||
<SettingRow
|
||||
label={t("Allow email authentication")}
|
||||
name="guestSignin"
|
||||
checked={data.guestSignin}
|
||||
onChange={handleChange}
|
||||
note={
|
||||
description={
|
||||
env.EMAIL_ENABLED
|
||||
? t("When enabled, users can sign-in using their email address")
|
||||
: t("The server must have SMTP configured to enable this setting")
|
||||
}
|
||||
disabled={!env.EMAIL_ENABLED}
|
||||
/>
|
||||
<Switch
|
||||
>
|
||||
<Switch
|
||||
id="guestSignin"
|
||||
checked={data.guestSignin}
|
||||
onChange={handleChange}
|
||||
disabled={!env.EMAIL_ENABLED}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("Public document sharing")}
|
||||
name="sharing"
|
||||
checked={data.sharing}
|
||||
onChange={handleChange}
|
||||
note={t(
|
||||
description={t(
|
||||
"When enabled, documents can be shared publicly on the internet by any team member"
|
||||
)}
|
||||
/>
|
||||
<Switch
|
||||
>
|
||||
<Switch id="sharing" checked={data.sharing} onChange={handleChange} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("Rich service embeds")}
|
||||
name="documentEmbeds"
|
||||
checked={data.documentEmbeds}
|
||||
onChange={handleChange}
|
||||
note={t(
|
||||
description={t(
|
||||
"Links to supported services are shown as rich embeds within your documents"
|
||||
)}
|
||||
/>
|
||||
<InputSelect
|
||||
value={data.defaultUserRole}
|
||||
label="Default role"
|
||||
options={[
|
||||
{
|
||||
label: t("Member"),
|
||||
value: "member",
|
||||
},
|
||||
{
|
||||
label: t("Viewer"),
|
||||
value: "viewer",
|
||||
},
|
||||
]}
|
||||
onChange={handleDefaultRoleChange}
|
||||
ariaLabel={t("Default role")}
|
||||
note={notes[data.defaultUserRole]}
|
||||
short
|
||||
/>
|
||||
>
|
||||
<Switch
|
||||
id="documentEmbeds"
|
||||
checked={data.documentEmbeds}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("Default role")}
|
||||
name="defaultUserRole"
|
||||
description={t(
|
||||
"The default user role for new accounts. Changing this setting does not affect existing user accounts."
|
||||
)}
|
||||
>
|
||||
<InputSelect
|
||||
id="defaultUserRole"
|
||||
value={data.defaultUserRole}
|
||||
options={[
|
||||
{
|
||||
label: t("Member"),
|
||||
value: "member",
|
||||
},
|
||||
{
|
||||
label: t("Viewer"),
|
||||
value: "viewer",
|
||||
},
|
||||
]}
|
||||
onChange={handleDefaultRoleChange}
|
||||
ariaLabel={t("Default role")}
|
||||
short
|
||||
/>
|
||||
</SettingRow>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { sortBy } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { LinkIcon } from "outline-icons";
|
||||
import { LinkIcon, WarningIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import Empty from "~/components/Empty";
|
||||
import { PAGINATION_SYMBOL } from "~/stores/BaseStore";
|
||||
import Share from "~/models/Share";
|
||||
import Heading from "~/components/Heading";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import Notice from "~/components/Notice";
|
||||
import Scene from "~/components/Scene";
|
||||
import Subheading from "~/components/Subheading";
|
||||
import Text from "~/components/Text";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import ShareListItem from "./components/ShareListItem";
|
||||
import SharesTable from "./components/SharesTable";
|
||||
|
||||
function Shares() {
|
||||
const team = useCurrentTeam();
|
||||
@@ -20,10 +22,64 @@ function Shares() {
|
||||
const { shares, auth } = useStores();
|
||||
const canShareDocuments = auth.team && auth.team.sharing;
|
||||
const can = usePolicy(team.id);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [data, setData] = React.useState<Share[]>([]);
|
||||
const [totalPages, setTotalPages] = React.useState(0);
|
||||
const [shareIds, setShareIds] = React.useState<string[]>([]);
|
||||
const params = useQuery();
|
||||
const query = params.get("query") || "";
|
||||
const sort = params.get("sort") || "createdAt";
|
||||
const direction = (params.get("direction") || "desc").toUpperCase() as
|
||||
| "ASC"
|
||||
| "DESC";
|
||||
const page = parseInt(params.get("page") || "0", 10);
|
||||
const limit = 25;
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await shares.fetchPage({
|
||||
offset: page * limit,
|
||||
limit,
|
||||
sort,
|
||||
direction,
|
||||
});
|
||||
setTotalPages(Math.ceil(response[PAGINATION_SYMBOL].total / limit));
|
||||
setShareIds(response.map((u: Share) => u.id));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [query, sort, page, direction, shares]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// sort the resulting data by the original order from the server
|
||||
setData(sortBy(shares.orderedData, (item) => shareIds.indexOf(item.id)));
|
||||
}, [shares.orderedData, shareIds]);
|
||||
|
||||
return (
|
||||
<Scene title={t("Share Links")} icon={<LinkIcon color="currentColor" />}>
|
||||
<Heading>{t("Share Links")}</Heading>
|
||||
|
||||
{can.manage && !canShareDocuments && (
|
||||
<>
|
||||
<Notice icon={<WarningIcon color="currentColor" />}>
|
||||
{t("Sharing is currently disabled.")}{" "}
|
||||
<Trans
|
||||
defaults="You can globally enable and disable public document sharing in the <em>security settings</em>."
|
||||
components={{
|
||||
em: <Link to="/settings/security" />,
|
||||
}}
|
||||
/>
|
||||
</Notice>
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text type="secondary">
|
||||
<Trans>
|
||||
Documents that have been shared are listed below. Anyone that has the
|
||||
@@ -31,25 +87,17 @@ function Shares() {
|
||||
link has been revoked.
|
||||
</Trans>
|
||||
</Text>
|
||||
{can.manage && (
|
||||
<Text type="secondary">
|
||||
{!canShareDocuments && (
|
||||
<strong>{t("Sharing is currently disabled.")}</strong>
|
||||
)}{" "}
|
||||
<Trans
|
||||
defaults="You can globally enable and disable public document sharing in the <em>security settings</em>."
|
||||
components={{
|
||||
em: <Link to="/settings/security" />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
<Subheading>{t("Shared documents")}</Subheading>
|
||||
<PaginatedList
|
||||
items={shares.published}
|
||||
empty={<Empty>{t("No share links, yet.")}</Empty>}
|
||||
fetch={shares.fetchPage}
|
||||
renderItem={(item) => <ShareListItem key={item.id} share={item} />}
|
||||
|
||||
<h2>{t("Shared documents")}</h2>
|
||||
|
||||
<SharesTable
|
||||
data={data}
|
||||
canManage={can.manage}
|
||||
isLoading={isLoading}
|
||||
page={page}
|
||||
pageSize={limit}
|
||||
totalPages={totalPages}
|
||||
defaultSortDirection="ASC"
|
||||
/>
|
||||
</Scene>
|
||||
);
|
||||
|
||||
@@ -2,39 +2,30 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Flex from "~/components/Flex";
|
||||
import { LabelText } from "~/components/Input";
|
||||
import ImageUpload, { Props as ImageUploadProps } from "./ImageUpload";
|
||||
|
||||
type Props = ImageUploadProps & {
|
||||
label: string;
|
||||
src?: string;
|
||||
};
|
||||
|
||||
export default function ImageInput({ label, src, ...rest }: Props) {
|
||||
export default function ImageInput({ src, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<InputWrapper column>
|
||||
<LabelText>{label}</LabelText>
|
||||
<ImageBox>
|
||||
<ImageUpload {...rest}>
|
||||
<Avatar src={src} />
|
||||
<Flex auto align="center" justify="center">
|
||||
{t("Upload")}
|
||||
</Flex>
|
||||
</ImageUpload>
|
||||
</ImageBox>
|
||||
</InputWrapper>
|
||||
<ImageBox>
|
||||
<ImageUpload {...rest}>
|
||||
<Avatar src={src} />
|
||||
<Flex auto align="center" justify="center">
|
||||
{t("Upload")}
|
||||
</Flex>
|
||||
</ImageUpload>
|
||||
</ImageBox>
|
||||
);
|
||||
}
|
||||
|
||||
const InputWrapper = styled(Flex)`
|
||||
margin-bottom: 24px;
|
||||
`;
|
||||
|
||||
const avatarStyles = `
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
`;
|
||||
|
||||
const Avatar = styled.img`
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import * as React from "react";
|
||||
import NotificationSetting from "~/models/NotificationSetting";
|
||||
import Switch from "~/components/Switch";
|
||||
|
||||
type Props = {
|
||||
setting?: NotificationSetting;
|
||||
title: string;
|
||||
event: string;
|
||||
description: string;
|
||||
disabled: boolean;
|
||||
onChange: (ev: React.SyntheticEvent) => void | Promise<void>;
|
||||
};
|
||||
|
||||
const NotificationListItem = ({
|
||||
setting,
|
||||
title,
|
||||
event,
|
||||
onChange,
|
||||
disabled,
|
||||
description,
|
||||
}: Props) => {
|
||||
return (
|
||||
<Switch
|
||||
label={title}
|
||||
name={event}
|
||||
checked={!!setting}
|
||||
onChange={onChange}
|
||||
note={description}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationListItem;
|
||||
@@ -2,29 +2,16 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { $Diff } from "utility-types";
|
||||
import User from "~/models/User";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Badge from "~/components/Badge";
|
||||
import Flex from "~/components/Flex";
|
||||
import { Props as TableProps } from "~/components/Table";
|
||||
import TableFromParams from "~/components/TableFromParams";
|
||||
import Time from "~/components/Time";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import UserMenu from "~/menus/UserMenu";
|
||||
|
||||
const Table = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "table" */
|
||||
"~/components/Table"
|
||||
)
|
||||
);
|
||||
type Props = $Diff<
|
||||
TableProps,
|
||||
{
|
||||
columns: any;
|
||||
}
|
||||
> & {
|
||||
type Props = Omit<React.ComponentProps<typeof TableFromParams>, "columns"> & {
|
||||
data: User[];
|
||||
canManage: boolean;
|
||||
};
|
||||
@@ -82,6 +69,7 @@ function PeopleTable({ canManage, ...rest }: Props) {
|
||||
Header: " ",
|
||||
accessor: "id",
|
||||
className: "actions",
|
||||
disableSortBy: true,
|
||||
Cell: observer(
|
||||
({ row, value }: { value: string; row: { original: User } }) =>
|
||||
currentUser.id !== value ? (
|
||||
@@ -94,7 +82,7 @@ function PeopleTable({ canManage, ...rest }: Props) {
|
||||
[t, canManage, currentUser]
|
||||
);
|
||||
|
||||
return <Table columns={columns} {...rest} />;
|
||||
return <TableFromParams columns={columns} {...rest} />;
|
||||
}
|
||||
|
||||
const Badges = styled.div`
|
||||
|
||||
72
app/scenes/Settings/components/SettingRow.tsx
Normal file
72
app/scenes/Settings/components/SettingRow.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
|
||||
type Props = {
|
||||
label: React.ReactNode;
|
||||
description: React.ReactNode;
|
||||
name: string;
|
||||
children: React.ReactNode;
|
||||
visible?: boolean;
|
||||
border?: boolean;
|
||||
};
|
||||
|
||||
const Row = styled(Flex)<{ $border?: boolean }>`
|
||||
display: block;
|
||||
padding: 24px 0;
|
||||
border-bottom: 1px solid
|
||||
${(props) =>
|
||||
props.$border === false
|
||||
? "transparent"
|
||||
: transparentize(0.5, props.theme.divider)};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
`};
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const Column = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-basis: 100%;
|
||||
flex: 1;
|
||||
|
||||
&:first-child {
|
||||
min-width: 60%;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
const Label = styled(Text)`
|
||||
margin-bottom: 4px;
|
||||
`;
|
||||
|
||||
export default function SettingRow(props: Props) {
|
||||
if (props.visible === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Row gap={32} $border={props.border}>
|
||||
<Column>
|
||||
<Label as="h3">
|
||||
<label htmlFor={props.name}>{props.label}</label>
|
||||
</Label>
|
||||
<Text type="secondary">{props.description}</Text>
|
||||
</Column>
|
||||
<Column>{props.children}</Column>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
82
app/scenes/Settings/components/SharesTable.tsx
Normal file
82
app/scenes/Settings/components/SharesTable.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "styled-components";
|
||||
import Share from "~/models/Share";
|
||||
import TableFromParams from "~/components/TableFromParams";
|
||||
import Time from "~/components/Time";
|
||||
import ShareMenu from "~/menus/ShareMenu";
|
||||
|
||||
type Props = Omit<React.ComponentProps<typeof TableFromParams>, "columns"> & {
|
||||
data: Share[];
|
||||
canManage: boolean;
|
||||
};
|
||||
|
||||
function SharesTable({ canManage, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
const columns = React.useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
id: "documentTitle",
|
||||
Header: t("Document"),
|
||||
accessor: "documentTitle",
|
||||
disableSortBy: true,
|
||||
Cell: observer(({ value }: { value: string }) => <>{value}</>),
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
Header: t("Date shared"),
|
||||
accessor: "createdAt",
|
||||
Cell: observer(
|
||||
({ value, row }: { value: string; row: { original: Share } }) =>
|
||||
value ? (
|
||||
<>
|
||||
<Time dateTime={value} addSuffix />{" "}
|
||||
{t("by {{ name }}", {
|
||||
name: row.original.createdBy.name,
|
||||
})}
|
||||
</>
|
||||
) : null
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "lastAccessedAt",
|
||||
Header: t("Last accessed"),
|
||||
accessor: "lastAccessedAt",
|
||||
Cell: observer(({ value }: { value: string }) =>
|
||||
value ? <Time dateTime={value} addSuffix /> : null
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "includeChildDocuments",
|
||||
Header: t("Shared nested"),
|
||||
accessor: "includeChildDocuments",
|
||||
Cell: observer(({ value }: { value: string }) =>
|
||||
value ? <CheckmarkIcon color={theme.primary} /> : null
|
||||
),
|
||||
},
|
||||
canManage
|
||||
? {
|
||||
Header: " ",
|
||||
accessor: "id",
|
||||
className: "actions",
|
||||
disableSortBy: true,
|
||||
Cell: observer(
|
||||
({ row }: { value: string; row: { original: Share } }) => (
|
||||
<ShareMenu share={row.original} />
|
||||
)
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
].filter((i) => i),
|
||||
[t, theme.primary, canManage]
|
||||
);
|
||||
|
||||
return <TableFromParams columns={columns} {...rest} />;
|
||||
}
|
||||
|
||||
export default SharesTable;
|
||||
@@ -120,44 +120,48 @@ router.post("shares.list", auth(), pagination(), async (ctx) => {
|
||||
}
|
||||
|
||||
const collectionIds = await user.collectionIds();
|
||||
const shares = await Share.findAll({
|
||||
where,
|
||||
order: [[sort, direction]],
|
||||
include: [
|
||||
{
|
||||
model: Document,
|
||||
required: true,
|
||||
paranoid: true,
|
||||
as: "document",
|
||||
where: {
|
||||
collectionId: collectionIds,
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}),
|
||||
as: "collection",
|
||||
|
||||
const [shares, total] = await Promise.all([
|
||||
Share.findAll({
|
||||
where,
|
||||
order: [[sort, direction]],
|
||||
include: [
|
||||
{
|
||||
model: Document,
|
||||
required: true,
|
||||
paranoid: true,
|
||||
as: "document",
|
||||
where: {
|
||||
collectionId: collectionIds,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
required: true,
|
||||
as: "user",
|
||||
},
|
||||
{
|
||||
model: Team,
|
||||
required: true,
|
||||
as: "team",
|
||||
},
|
||||
],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
});
|
||||
include: [
|
||||
{
|
||||
model: Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}),
|
||||
as: "collection",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
required: true,
|
||||
as: "user",
|
||||
},
|
||||
{
|
||||
model: Team,
|
||||
required: true,
|
||||
as: "team",
|
||||
},
|
||||
],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
}),
|
||||
Share.count({ where }),
|
||||
]);
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
pagination: { ...ctx.state.pagination, total },
|
||||
data: shares.map((share) => presentShare(share, user.isAdmin)),
|
||||
policies: presentPolicies(user, shares),
|
||||
};
|
||||
|
||||
@@ -67,9 +67,7 @@
|
||||
"Edits you make will sync once you’re online": "Edits you make will sync once you’re online",
|
||||
"Submenu": "Submenu",
|
||||
"Collections could not be loaded, please reload the app": "Collections could not be loaded, please reload the app",
|
||||
"Start view": "Start view",
|
||||
"Default collection": "Default collection",
|
||||
"This is the screen that team members will first see when they sign in.": "This is the screen that team members will first see when they sign in.",
|
||||
"Deleted Collection": "Deleted Collection",
|
||||
"Unpin": "Unpin",
|
||||
"History": "History",
|
||||
@@ -533,6 +531,8 @@
|
||||
"Shared": "Shared",
|
||||
"by {{ name }}": "by {{ name }}",
|
||||
"Last accessed": "Last accessed",
|
||||
"Date shared": "Date shared",
|
||||
"Shared nested": "Shared nested",
|
||||
"Add to Slack": "Add to Slack",
|
||||
"Settings saved": "Settings saved",
|
||||
"document published": "document published",
|
||||
@@ -547,10 +547,15 @@
|
||||
"Admins": "Admins",
|
||||
"Logo updated": "Logo updated",
|
||||
"Unable to upload new logo": "Unable to upload new logo",
|
||||
"These details affect the way that your Outline appears to everyone on the team.": "These details affect the way that your Outline appears to everyone on the team.",
|
||||
"These settings affect the way that your knowledge base appears to everyone on the team.": "These settings affect the way that your knowledge base appears to everyone on the team.",
|
||||
"Logo": "Logo",
|
||||
"The logo is displayed at the top left of the application.": "The logo is displayed at the top left of the application.",
|
||||
"The team name, usually the same as your company name.": "The team name, usually the same as your company name.",
|
||||
"Subdomain": "Subdomain",
|
||||
"Your knowledge base will be accessible at": "Your knowledge base will be accessible at",
|
||||
"Choose a subdomain to enable a login page just for your team.": "Choose a subdomain to enable a login page just for your team.",
|
||||
"Start view": "Start view",
|
||||
"This is the screen that team members will first see when they sign in.": "This is the screen that team members will first see when they sign in.",
|
||||
"Export in progress…": "Export in progress…",
|
||||
"Export deleted": "Export deleted",
|
||||
"A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started – we will email a link to <em>{{ userEmail }}</em> when it’s complete.": "A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started – we will email a link to <em>{{ userEmail }}</em> when it’s complete.",
|
||||
@@ -585,20 +590,21 @@
|
||||
"Receive an email when new features of note are added": "Receive an email when new features of note are added",
|
||||
"Notifications saved": "Notifications saved",
|
||||
"Unsubscription successful. Your notification settings were updated": "Unsubscription successful. Your notification settings were updated",
|
||||
"Manage when and where you receive email notifications from Outline. Your email address can be updated in your SSO provider.": "Manage when and where you receive email notifications from Outline. Your email address can be updated in your SSO provider.",
|
||||
"Manage when and where you receive email notifications.": "Manage when and where you receive email notifications.",
|
||||
"Email address": "Email address",
|
||||
"Your email address should be updated in your SSO provider.": "Your email address should be updated in your SSO provider.",
|
||||
"The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.": "The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.",
|
||||
"Profile saved": "Profile saved",
|
||||
"Profile picture updated": "Profile picture updated",
|
||||
"Unable to upload new profile picture": "Unable to upload new profile picture",
|
||||
"Photo": "Photo",
|
||||
"Choose a photo or image to represent yourself.": "Choose a photo or image to represent yourself.",
|
||||
"This could be your real name, or a nickname — however you’d like people to refer to you.": "This could be your real name, or a nickname — however you’d like people to refer to you.",
|
||||
"Language": "Language",
|
||||
"Please note that translations are currently in early access.<1></1>Community contributions are accepted though our <4>translation portal</4>": "Please note that translations are currently in early access.<1></1>Community contributions are accepted though our <4>translation portal</4>",
|
||||
"Please note that translations are currently in early access. Community contributions are accepted though our <2>translation portal</2>.": "Please note that translations are currently in early access. Community contributions are accepted though our <2>translation portal</2>.",
|
||||
"Delete Account": "Delete Account",
|
||||
"You may delete your account at any time, note that this is unrecoverable": "You may delete your account at any time, note that this is unrecoverable",
|
||||
"Delete account": "Delete account",
|
||||
"New user accounts will be given member permissions by default": "New user accounts will be given member permissions by default",
|
||||
"New user accounts will be given viewer permissions by default": "New user accounts will be given viewer permissions by default",
|
||||
"Settings that impact the access, security, and content of your knowledge base.": "Settings that impact the access, security, and content of your knowledge base.",
|
||||
"Allow email authentication": "Allow email authentication",
|
||||
"When enabled, users can sign-in using their email address": "When enabled, users can sign-in using their email address",
|
||||
@@ -607,11 +613,11 @@
|
||||
"Rich service embeds": "Rich service embeds",
|
||||
"Links to supported services are shown as rich embeds within your documents": "Links to supported services are shown as rich embeds within your documents",
|
||||
"Default role": "Default role",
|
||||
"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.": "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.",
|
||||
"The default user role for new accounts. Changing this setting does not affect existing user accounts.": "The default user role for new accounts. Changing this setting does not affect existing user accounts.",
|
||||
"Sharing is currently disabled.": "Sharing is currently disabled.",
|
||||
"You can globally enable and disable public document sharing in the <em>security settings</em>.": "You can globally enable and disable public document sharing in the <em>security settings</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.": "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.",
|
||||
"Shared documents": "Shared documents",
|
||||
"No share links, yet.": "No share links, yet.",
|
||||
"Whoops, you need to accept the permissions in Slack to connect Outline to your team. Try again?": "Whoops, you need to accept the permissions in Slack to connect Outline to your team. Try again?",
|
||||
"Something went wrong while authenticating your request. Please try logging in again?": "Something went wrong while authenticating your request. Please try logging in again?",
|
||||
"Get rich previews of Outline links shared in Slack and use the <em>{{ command }}</em> slash command to search for documents without leaving your chat.": "Get rich previews of Outline links shared in Slack and use the <em>{{ command }}</em> slash command to search for documents without leaving your chat.",
|
||||
|
||||
Reference in New Issue
Block a user