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:
Tom Moor
2022-03-14 17:44:56 -07:00
committed by GitHub
parent 1633bbf5aa
commit d63326066f
26 changed files with 682 additions and 411 deletions

View File

@@ -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}
/>
);
};

View File

@@ -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;

View File

@@ -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};

View File

@@ -49,6 +49,7 @@ function Scrollable(
React.useEffect(() => {
updateShadows();
}, [height, updateShadows]);
return (
<Wrapper
ref={ref || fallbackRef}

View File

@@ -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;
`;

View File

@@ -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 />

View File

@@ -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;

View File

@@ -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;

View 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;

View File

@@ -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"

View File

@@ -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>

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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} />

View File

@@ -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"
/>

View File

@@ -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);

View File

@@ -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 youd 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

View File

@@ -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>
);
}

View File

@@ -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>
);

View File

@@ -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`

View File

@@ -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;

View File

@@ -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`

View 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>
);
}

View 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;

View File

@@ -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),
};

View File

@@ -67,9 +67,7 @@
"Edits you make will sync once youre online": "Edits you make will sync once youre 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 its 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 its 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 youd like people to refer to you.": "This could be your real name, or a nickname — however youd 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.",